abs-spend-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # abs-spend-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io/) server that exposes **Australian household
4
+ spending** data — so Claude (Claude Code, Claude Desktop, or any MCP client) can answer
5
+ questions like *"how did Victorian transport spending move year-on-year?"* directly in
6
+ conversation.
7
+
8
+ Data comes live from the [ABS Data API](https://data.api.abs.gov.au) (SDMX), plus a bundled
9
+ static extract of National Accounts sub-category detail. It runs server-side over stdio, so
10
+ there's no CORS proxy and nothing to configure — just point your client at it.
11
+
12
+ ## Tools
13
+
14
+ | Tool | Answers |
15
+ |---|---|
16
+ | `get_spending_trend` | Monthly time series for any category/state — `$` level or year-on-year `%`, Original / Seasonally adjusted / Trend series |
17
+ | `get_latest_summary` | Latest month: total spend, nominal **and** real (CPI-deflated) YoY growth, top category, discretionary share |
18
+ | `get_category_breakdown` | All 9 COICOP categories for a month/geography, with shares of total |
19
+ | `get_l2_composition` | Level-2 sub-category split (e.g. Transport → vehicle purchase / operation / services) with YoY |
20
+ | `get_cost_of_living` | Living Cost Indexes by household type (YoY or index) — includes mortgage interest, unlike CPI |
21
+
22
+ Categories, states, and series types take friendly names (`transport`, `VIC`,
23
+ `seasonally_adjusted`) — see each tool's schema for the full set.
24
+
25
+ ## Use with Claude Code
26
+
27
+ ```bash
28
+ claude mcp add --scope user abs-spend -- npx -y abs-spend-mcp
29
+ ```
30
+
31
+ Then start any `claude` session and ask a spending question, or headless:
32
+
33
+ ```bash
34
+ claude -p "Use the abs-spend tools: what's the latest Australian household spending summary?"
35
+ ```
36
+
37
+ ## Use with Claude Desktop
38
+
39
+ Add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`):
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "abs-spend": { "command": "npx", "args": ["-y", "abs-spend-mcp"] }
45
+ }
46
+ }
47
+ ```
48
+
49
+ Restart Claude Desktop; the tools appear under the 🔌 connectors menu.
50
+
51
+ ## Run locally
52
+
53
+ ```bash
54
+ npm install
55
+ npm test # end-to-end smoke test against the live ABS API
56
+ npm start # run the server on stdio
57
+ ```
58
+
59
+ Requires **Node 18+** (uses the built-in `fetch`).
60
+
61
+ ## Data sources & caveats
62
+
63
+ - **Live:** Monthly Household Spending Indicator (`HSI_M`), Monthly CPI Indicator (`CPI_M`),
64
+ Selected Living Cost Indexes (`LCI`). Responses are cached in-process for 10 minutes.
65
+ - **Static:** Level-2 detail is from National Accounts 5206.0 Table 8 (HFCE) — quarterly,
66
+ national, current-price. Bundled as `hfce.json`; refresh it in sync with the parent repo's
67
+ `src/data/hfce.json` (see the app's `scripts/extract_hfce.py`).
68
+ - Dollar values are `$` millions, current prices (nominal). Real YoY deflates by the
69
+ all-groups CPI (8-capital-city average).
70
+ - The ABS API does **not** guarantee chronological observation order; the server sorts
71
+ periods defensively before any YoY / deflation math.
72
+ - No sub-state (regional) spend is published — the finest geography is state/territory.
73
+
74
+ Part of the [Australian Household Spending Analytics](https://github.com/skannan02/australian-household-spending)
75
+ project.
package/hfce.json ADDED
@@ -0,0 +1 @@
1
+ {"source": "ABS National Accounts 5206.0 Table 8 (HFCE)", "release": "March 2026", "unit": "$ millions, current prices", "seriesType": "Original", "frequency": "Quarterly", "periods": ["2015-Q3", "2015-Q4", "2016-Q1", "2016-Q2", "2016-Q3", "2016-Q4", "2017-Q1", "2017-Q2", "2017-Q3", "2017-Q4", "2018-Q1", "2018-Q2", "2018-Q3", "2018-Q4", "2019-Q1", "2019-Q2", "2019-Q3", "2019-Q4", "2020-Q1", "2020-Q2", "2020-Q3", "2020-Q4", "2021-Q1", "2021-Q2", "2021-Q3", "2021-Q4", "2022-Q1", "2022-Q2", "2022-Q3", "2022-Q4", "2023-Q1", "2023-Q2", "2023-Q3", "2023-Q4", "2024-Q1", "2024-Q2", "2024-Q3", "2024-Q4", "2025-Q1", "2025-Q2", "2025-Q3", "2025-Q4", "2026-Q1"], "series": {"A3605689A": [10947, 14323, 10785, 11105, 11082, 14440, 10781, 11328, 11359, 14886, 11538, 11837, 11910, 15413, 11481, 11960, 12120, 15907, 12221, 14153, 14616, 18508, 13705, 14079, 13848, 19746, 15438, 16014, 16417, 20510, 15504, 15936, 15801, 20323, 14970, 15791, 16139, 20600, 15456, 16504, 17096, 21665, 16366], "A3605688X": [26263, 25738, 24396, 25651, 25730, 26097, 25545, 26537, 26604, 27144, 26538, 27992, 28163, 28052, 26802, 28620, 28750, 28567, 25315, 14677, 16842, 20421, 20761, 23006, 19759, 23157, 26070, 30241, 33083, 35019, 34147, 36577, 38709, 38510, 37162, 38636, 38421, 38595, 36660, 39117, 39607, 39717, 37713], "A2302247X": [8140, 7625, 6903, 7643, 8162, 8063, 7059, 7860, 8241, 8145, 7424, 8418, 9053, 8748, 7813, 8940, 9572, 9046, 6900, 1113, 1535, 2024, 2019, 2746, 1885, 3114, 4478, 7543, 9630, 10933, 10028, 11403, 12340, 12160, 11129, 12160, 12857, 12998, 11270, 12871, 13472, 13739, 11805], "A2302240J": [7928, 10414, 7498, 8673, 8313, 10856, 7546, 8720, 8327, 10911, 7741, 9045, 8623, 11381, 7984, 9333, 8941, 11825, 7655, 7030, 8181, 12342, 8605, 9947, 6985, 13412, 9775, 11972, 11614, 15435, 10662, 12388, 11891, 14864, 10722, 12193, 11862, 15526, 11124, 12944, 12532, 16411, 11720], "A2302239X": [4609, 5070, 4653, 4746, 4905, 5294, 4710, 4895, 4963, 5523, 4907, 5119, 5296, 5675, 5066, 5197, 5283, 5778, 5380, 6169, 6263, 6796, 5854, 5891, 6466, 6964, 6083, 6417, 6582, 7326, 6464, 6748, 6848, 7634, 6409, 6491, 6548, 7416, 6274, 6577, 6689, 7531, 6407], "A2302244T": [15443, 15094, 14651, 15898, 15979, 15876, 15887, 16623, 16801, 16810, 16656, 17640, 17754, 17849, 17704, 18420, 18791, 18750, 17982, 14733, 18551, 19452, 19196, 19994, 19010, 20404, 19881, 21358, 22185, 22325, 22246, 23186, 24006, 24174, 23924, 25376, 25732, 26168, 25945, 26973, 27865, 28287, 27834], "A3605684R": [2101, 2623, 2080, 2153, 2154, 2700, 2159, 2254, 2211, 2746, 2161, 2257, 2221, 2800, 2186, 2263, 2248, 2814, 2272, 2706, 2743, 3392, 2672, 2743, 2667, 3630, 2913, 3030, 3053, 3657, 2848, 2945, 2934, 3673, 2849, 2973, 2944, 3911, 2963, 3057, 3163, 3972, 3039], "A2302238W": [4050, 4313, 4150, 4097, 4090, 4495, 4429, 4400, 4552, 5217, 5065, 4990, 5041, 5645, 5409, 5324, 5337, 5740, 5532, 5387, 5529, 5987, 5743, 5606, 5583, 5676, 5424, 5152, 5086, 5242, 4926, 4537, 4320, 4264, 3910, 3840, 3817, 3919, 3631, 3238, 3206, 3092, 2863], "A3605694V": [12681, 13384, 12737, 12986, 13521, 13867, 12951, 13514, 13905, 14884, 13786, 14403, 15028, 15472, 14292, 14783, 15233, 15939, 13403, 6792, 10627, 13044, 13521, 14109, 11672, 15276, 14838, 16996, 18887, 20325, 18946, 19827, 20931, 21199, 19982, 20421, 20985, 22252, 20524, 21701, 22269, 23467, 21363], "A2302237V": [22179, 23960, 22609, 22223, 22920, 24834, 23170, 23314, 23618, 25508, 24020, 24059, 24572, 26547, 25169, 25080, 25446, 27460, 27743, 26880, 28076, 29299, 26979, 27051, 29171, 30496, 29012, 29023, 30360, 33523, 31827, 32111, 32604, 35099, 32878, 32527, 33484, 36303, 34255, 33988, 34892, 37933, 35707], "A2302251R": [17821, 18117, 16817, 17938, 19097, 18891, 17411, 18446, 19488, 20474, 18658, 19662, 20761, 21144, 19249, 20413, 21269, 22002, 17994, 8952, 13715, 16394, 17075, 18449, 15240, 19533, 19372, 22601, 25709, 27905, 26247, 28497, 30437, 30658, 28077, 29105, 30665, 31756, 28878, 31099, 32688, 34470, 30986], "A3605685T": [1553, 1813, 1527, 1530, 1636, 1927, 1519, 1537, 1597, 1843, 1526, 1536, 1622, 1896, 1593, 1594, 1681, 1937, 1705, 2112, 2087, 2384, 1948, 1916, 2012, 2456, 2081, 2101, 2198, 2510, 2077, 2034, 2285, 2562, 2190, 2131, 2182, 2626, 2197, 2224, 2301, 2757, 2278], "A3605696X": [33304, 34962, 33266, 34940, 34741, 36834, 34959, 36905, 36401, 38147, 36028, 37684, 37170, 39095, 36914, 38736, 37802, 39480, 36698, 33103, 34383, 38051, 37027, 39080, 38016, 41115, 39619, 40685, 42295, 44929, 43204, 44626, 44344, 46376, 45110, 46158, 47656, 49696, 47975, 49618, 51171, 53720, 51854], "A3605697A": [5433, 6542, 5224, 5563, 5635, 6889, 5369, 5719, 5787, 7038, 5481, 5888, 6013, 7238, 5688, 6052, 6059, 7335, 5999, 5184, 5733, 7306, 6109, 6407, 6255, 8526, 7048, 7558, 7916, 9568, 7446, 7942, 7959, 9228, 7369, 7748, 7939, 9512, 7610, 8109, 8381, 10158, 8066], "A3605816F": [8658, 9383, 8803, 8843, 8995, 9790, 9139, 9295, 9514, 10740, 9972, 10109, 10337, 11320, 10475, 10520, 10620, 11518, 10912, 11556, 11792, 12783, 11597, 11497, 12049, 12640, 11507, 11569, 11668, 12569, 11390, 11285, 11168, 11898, 10319, 10331, 10365, 11336, 9904, 9814, 9894, 10622, 9270], "A2302243R": [10715, 12867, 10526, 10695, 11109, 13329, 10513, 10933, 11111, 13279, 10786, 11152, 11374, 13592, 10847, 11142, 11400, 13677, 11341, 12844, 13292, 16012, 12905, 13134, 13001, 16900, 13967, 14473, 14838, 17920, 13904, 13993, 14509, 17376, 13795, 14333, 14559, 18140, 14114, 14624, 15025, 18615, 14633], "A3605683L": [7061, 8432, 6919, 7011, 7319, 8702, 6835, 7142, 7303, 8690, 7099, 7359, 7530, 8895, 7069, 7286, 7471, 8926, 7364, 8027, 8462, 10235, 8285, 8475, 8322, 10815, 8974, 9342, 9587, 11753, 8979, 9013, 9290, 11141, 8756, 9229, 9434, 11602, 8954, 9342, 9562, 11886, 9315], "A3605687W": [11029, 10433, 10292, 11564, 11528, 11462, 11388, 11812, 12053, 12031, 11753, 12632, 12525, 12521, 12390, 12978, 13202, 13150, 12066, 9664, 13009, 13708, 13213, 14005, 12943, 13998, 13502, 14848, 15685, 15725, 15916, 16577, 17393, 17281, 17291, 18494, 18691, 18685, 18985, 19707, 20355, 20163, 20291], "A2302249C": [24281, 28013, 23554, 24128, 24651, 28123, 23653, 24476, 25248, 29358, 24719, 25701, 26340, 30024, 24868, 25897, 26400, 30426, 24952, 21735, 25129, 31449, 27219, 28038, 25461, 34208, 30475, 33076, 33972, 39066, 32857, 33646, 34666, 39291, 33419, 34343, 35674, 40611, 34585, 36361, 37625, 42824, 36676], "A3605686V": [4414, 4661, 4359, 4334, 4451, 4414, 4499, 4810, 4748, 4779, 4903, 5008, 5229, 5328, 5314, 5442, 5589, 5601, 5916, 5069, 5542, 5744, 5983, 5989, 6066, 6406, 6379, 6511, 6500, 6600, 6330, 6609, 6613, 6892, 6633, 6882, 7041, 7483, 6960, 7266, 7510, 8123, 7543], "A2302245V": [5615, 5718, 5760, 6318, 5642, 5697, 5898, 6300, 5699, 5727, 5947, 6135, 5501, 5452, 5778, 6007, 5406, 5252, 5256, 4599, 5017, 6707, 6764, 7872, 6654, 6613, 7741, 7974, 8097, 8336, 8537, 9470, 9959, 9590, 9623, 9974, 9075, 8841, 9137, 10071, 9700, 9071, 9047], "A2302252T": [18986, 19462, 18997, 20242, 19852, 20653, 20238, 21750, 21070, 21501, 20798, 21880, 21202, 21774, 21094, 22551, 21652, 22022, 20997, 21810, 21116, 21927, 21518, 22974, 22729, 23290, 22929, 22867, 23835, 24652, 24772, 25432, 25464, 26020, 26371, 26830, 27793, 28134, 28178, 29132, 30061, 30662, 30766], "A3606485T": [8885, 8958, 9045, 9135, 9253, 9292, 9352, 9437, 9544, 9608, 9750, 9915, 9955, 10083, 10133, 10133, 10090, 10123, 9702, 6110, 7535, 8818, 9399, 9700, 9032, 9299, 9642, 10260, 10544, 10710, 10986, 11253, 10921, 11128, 11370, 11580, 11925, 12050, 12187, 12377, 12729, 12900, 13022], "A3605690K": [11310, 11286, 10764, 11091, 11584, 11343, 10913, 11229, 11917, 12183, 11295, 11980, 12492, 12372, 11547, 12109, 12421, 12396, 11040, 5997, 8883, 10837, 11795, 12210, 10020, 12310, 13252, 15307, 15786, 16340, 15578, 15959, 17079, 16851, 16758, 16822, 17755, 17836, 17413, 18084, 18728, 18962, 18560], "A2302246W": [12508, 12395, 11733, 11690, 11926, 12338, 12589, 12377, 12663, 13273, 13167, 13439, 13609, 13852, 13212, 13674, 13772, 14269, 13159, 8964, 10289, 11690, 11978, 12388, 11220, 13430, 13852, 14723, 15355, 15750, 15582, 15704, 16411, 16760, 16409, 16502, 16489, 16756, 16253, 16175, 16435, 16906, 16861], "A3605695W": [5140, 4733, 4080, 4951, 5576, 5024, 4460, 4932, 5583, 5590, 4872, 5259, 5733, 5672, 4957, 5630, 6035, 6063, 4591, 2161, 3088, 3350, 3554, 4340, 3569, 4257, 4534, 5605, 6822, 7581, 7300, 8670, 9506, 9459, 8095, 8684, 9680, 9504, 8354, 9399, 10420, 11002, 9623]}}
package/index.js ADDED
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ // MCP server for the Australian household spending analytics app.
3
+ // Exposes the same ABS data the web app shows — Monthly Household Spending
4
+ // Indicator (HSI), CPI, Living Cost Indexes (LCI), and the static National
5
+ // Accounts HFCE L2 bundle — as tools Claude can query directly.
6
+ //
7
+ // Runs server-side, so it calls https://data.api.abs.gov.au directly:
8
+ // no CORS proxy needed (unlike the browser app, see ../vite.config.js).
9
+
10
+ import { readFileSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { dirname, join } from 'node:path'
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
15
+ import { z } from 'zod'
16
+ import { CATEGORIES, ANALYTICAL, STATES } from './meta.js'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const API = 'https://data.api.abs.gov.au/rest/data'
20
+ const HSI = 'ABS,HSI_M,1.6.0'
21
+ const CPI = 'ABS,CPI_M,1.2.0'
22
+ const LCI = 'ABS,LCI,1.0.0'
23
+
24
+ // Static L2 bundle (quarterly HFCE, National Accounts). Refresh in sync with
25
+ // the web app's ../src/data/hfce.json — see README.
26
+ const hfce = JSON.parse(readFileSync(join(__dirname, 'hfce.json'), 'utf8'))
27
+
28
+ // ---- friendly-name maps -----------------------------------------------------
29
+
30
+ const CATEGORY_KEYS = {
31
+ total: ANALYTICAL.TOTAL,
32
+ discretionary: ANALYTICAL.DISCRETIONARY,
33
+ non_discretionary: ANALYTICAL.NON_DISCRETIONARY,
34
+ food: '10',
35
+ alcohol_tobacco: '15',
36
+ clothing: '20',
37
+ furnishings: '30',
38
+ health: '35',
39
+ transport: '40',
40
+ recreation: '50',
41
+ hotels_cafes: '60',
42
+ miscellaneous: '65',
43
+ }
44
+ const CATEGORY_ENUM = Object.keys(CATEGORY_KEYS)
45
+ const LABEL_BY_CODE = Object.fromEntries(CATEGORIES.map((c) => [c.code, c.label]))
46
+ LABEL_BY_CODE[ANALYTICAL.TOTAL] = 'Total household spending'
47
+ LABEL_BY_CODE[ANALYTICAL.DISCRETIONARY] = 'Discretionary'
48
+ LABEL_BY_CODE[ANALYTICAL.NON_DISCRETIONARY] = 'Non-discretionary'
49
+
50
+ const STATE_ENUM = STATES.map((s) => s.abbr)
51
+ const stateCode = (abbr) => STATES.find((s) => s.abbr === abbr).code
52
+
53
+ const SERIES_TYPES = { original: '10', seasonally_adjusted: '20', trend: '30' }
54
+
55
+ // L1 category -> National Accounts parent + L2 children (mirrors src/lib/hfce.js,
56
+ // which can't be imported here because it uses a Vite-only JSON import).
57
+ const HFCE_MAP = {
58
+ 10: { parent: 'A2302237V', children: [] },
59
+ 15: { parent: 'A3605816F', children: [
60
+ ['Alcoholic beverages', 'A2302239X'], ['Cigarettes & tobacco', 'A2302238W'],
61
+ ] },
62
+ 20: { parent: 'A2302240J', children: [] },
63
+ 30: { parent: 'A2302243R', children: [
64
+ ['Furniture, floor coverings & goods', 'A3605683L'], ['Household appliances', 'A3605684R'], ['Household tools', 'A3605685T'],
65
+ ] },
66
+ 35: { parent: 'A2302244T', children: [
67
+ ['Medicines, aids & appliances', 'A3605686V'], ['Total health services', 'A3605687W'],
68
+ ] },
69
+ 40: { parent: 'A3605688X', children: [
70
+ ['Purchase of vehicles', 'A2302245V'], ['Operation of vehicles', 'A2302246W'], ['Transport services', 'A2302247X'],
71
+ ] },
72
+ 50: { parent: 'A2302249C', children: [
73
+ ['Goods for recreation & culture', 'A3605689A'], ['Recreational & cultural services', 'A3605690K'],
74
+ ] },
75
+ 60: { parent: 'A2302251R', children: [
76
+ ['Catering services', 'A3605694V'], ['Accommodation services', 'A3605695W'],
77
+ ] },
78
+ 65: { parent: 'A3605696X', children: [
79
+ ['Other goods', 'A3605697A'], ['Insurance & financial services', 'A2302252T'], ['Other services', 'A3606485T'],
80
+ ] },
81
+ }
82
+
83
+ const LCI_TYPES = {
84
+ employee: { code: 'P1', label: 'Employee households' },
85
+ age_pensioner: { code: 'P2', label: 'Age pensioner households' },
86
+ other_government_transfer: { code: 'P3', label: 'Other government transfer recipient households' },
87
+ self_funded_retiree: { code: 'P4', label: 'Self-funded retiree households' },
88
+ pensioner_beneficiary: { code: 'PBLCI', label: 'Pensioner & beneficiary households (aggregate)' },
89
+ }
90
+
91
+ // ---- SDMX client ------------------------------------------------------------
92
+
93
+ // 10-minute cache: ABS data changes monthly/quarterly, and Claude may call
94
+ // several tools in one conversation that hit the same underlying request.
95
+ const cache = new Map()
96
+ const CACHE_MS = 10 * 60 * 1000
97
+
98
+ async function sdmx(dataflow, key, startPeriod) {
99
+ const url = `${API}/${dataflow}/${key}?startPeriod=${startPeriod}&format=jsondata`
100
+ const hit = cache.get(url)
101
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.json
102
+ const res = await fetch(url, { headers: { Accept: 'application/vnd.sdmx.data+json' } })
103
+ if (!res.ok) throw new Error(`ABS API ${res.status} ${res.statusText} for ${dataflow}/${key}`)
104
+ const json = await res.json()
105
+ cache.set(url, { at: Date.now(), json })
106
+ return json
107
+ }
108
+
109
+ // Parse SDMX-JSON into { periods, series } where series is keyed by the values
110
+ // of the requested dimensions, joined with "|".
111
+ // The ABS API does NOT guarantee chronological observation order (single-series
112
+ // requests have been observed shuffled), and the YoY math depends on order —
113
+ // so sort periods and remap every observation index.
114
+ function parse(json, dimIds) {
115
+ const struct = json.data.structures[0]
116
+ const seriesDims = struct.dimensions.series
117
+ const positions = dimIds.map((id) => seriesDims.findIndex((d) => d.id === id))
118
+ const rawPeriods = struct.dimensions.observation[0].values.map((v) => v.id)
119
+ const order = rawPeriods.map((p, i) => [p, i]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
120
+ const periods = order.map(([p]) => p)
121
+ const newIndex = new Array(order.length)
122
+ order.forEach(([, oldI], newI) => { newIndex[oldI] = newI })
123
+ const series = {}
124
+ for (const [key, body] of Object.entries(json.data.dataSets[0].series)) {
125
+ const parts = key.split(':').map(Number)
126
+ const seriesKey = positions.map((pos) => seriesDims[pos].values[parts[pos]].id).join('|')
127
+ const arr = new Array(periods.length).fill(null)
128
+ for (const [obsIdx, val] of Object.entries(body.observations)) {
129
+ arr[newIndex[Number(obsIdx)]] = val[0] == null ? null : Number(val[0])
130
+ }
131
+ series[seriesKey] = arr
132
+ }
133
+ return { periods, series }
134
+ }
135
+
136
+ const toYoY = (arr, lag) =>
137
+ arr.map((v, i) =>
138
+ i < lag || v == null || arr[i - lag] == null || arr[i - lag] === 0
139
+ ? null
140
+ : Number((((v - arr[i - lag]) / arr[i - lag]) * 100).toFixed(2)),
141
+ )
142
+
143
+ const lastValid = (arr) => {
144
+ for (let i = arr.length - 1; i >= 0; i--) if (arr[i] != null) return i
145
+ return -1
146
+ }
147
+
148
+ async function fetchHsi(catCodes, state, tsest, startPeriod = '2019-01') {
149
+ const key = ['7', catCodes.join('+'), 'CUR', tsest, state, 'M'].join('.')
150
+ return parse(await sdmx(HSI, key, startPeriod), ['CATEGORY', 'STATE'])
151
+ }
152
+
153
+ const text = (obj) => ({ content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] })
154
+ const errText = (e) => ({ content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true })
155
+
156
+ // ---- server & tools ---------------------------------------------------------
157
+
158
+ const server = new McpServer({ name: 'abs-household-spending', version: '0.1.0' })
159
+
160
+ server.registerTool(
161
+ 'get_spending_trend',
162
+ {
163
+ title: 'Australian household spending trend',
164
+ description:
165
+ 'Monthly Australian household spending from the ABS Monthly Household Spending Indicator (live). ' +
166
+ 'Returns a monthly time series in $ millions (current prices) or year-on-year % change. ' +
167
+ 'Covers 9 COICOP categories plus total/discretionary/non-discretionary, national or by state.',
168
+ inputSchema: {
169
+ category: z.enum(CATEGORY_ENUM).default('total').describe('Spending category'),
170
+ state: z.enum(STATE_ENUM).default('AUS').describe('Geography (AUS = national)'),
171
+ series_type: z.enum(['original', 'seasonally_adjusted', 'trend']).default('original')
172
+ .describe('ABS time-series estimate type. Trend is suspended by ABS during volatile periods (gaps are real).'),
173
+ measure: z.enum(['level', 'yoy']).default('level').describe('level = $ millions/month, yoy = % change vs same month last year'),
174
+ start_period: z.string().regex(/^\d{4}-\d{2}$/).default('2019-01').describe('First month, YYYY-MM'),
175
+ },
176
+ },
177
+ async ({ category, state, series_type, measure, start_period }) => {
178
+ try {
179
+ const code = CATEGORY_KEYS[category]
180
+ const st = stateCode(state)
181
+ // YoY needs 12 months of run-up before the requested start.
182
+ const fetchStart = measure === 'yoy' ? `${Number(start_period.slice(0, 4)) - 1}-${start_period.slice(5)}` : start_period
183
+ let { periods, series } = await fetchHsi([code], st, SERIES_TYPES[series_type], fetchStart)
184
+ let values = series[`${code}|${st}`] || []
185
+ if (measure === 'yoy') {
186
+ values = toYoY(values, 12)
187
+ const from = periods.findIndex((p) => p >= start_period)
188
+ periods = periods.slice(from)
189
+ values = values.slice(from)
190
+ }
191
+ return text({
192
+ source: 'ABS Monthly Household Spending Indicator (live SDMX API)',
193
+ category: LABEL_BY_CODE[code],
194
+ state,
195
+ series_type,
196
+ unit: measure === 'yoy' ? '% change year-on-year' : '$ millions per month, current prices (nominal)',
197
+ latest_period: periods[lastValid(values)] ?? null,
198
+ data: Object.fromEntries(periods.map((p, i) => [p, values[i]]).filter(([, v]) => v != null)),
199
+ })
200
+ } catch (e) { return errText(e) }
201
+ },
202
+ )
203
+
204
+ server.registerTool(
205
+ 'get_latest_summary',
206
+ {
207
+ title: 'Latest household spending summary',
208
+ description:
209
+ 'Headline summary of the latest month of Australian household spending: total spend, nominal and ' +
210
+ 'real (CPI-adjusted) year-on-year growth, largest category, and discretionary share. Live ABS data.',
211
+ inputSchema: {
212
+ state: z.enum(STATE_ENUM).default('AUS').describe('Geography (AUS = national)'),
213
+ },
214
+ },
215
+ async ({ state }) => {
216
+ try {
217
+ const st = stateCode(state)
218
+ const allCodes = [ANALYTICAL.TOTAL, ...CATEGORIES.map((c) => c.code), ANALYTICAL.DISCRETIONARY]
219
+ const [{ periods, series }, cpiJson] = await Promise.all([
220
+ fetchHsi(allCodes, st, '10'),
221
+ sdmx(CPI, '1.10001.10.50.M', '2019-01'),
222
+ ])
223
+ const total = series[`${ANALYTICAL.TOTAL}|${st}`] || []
224
+ const idx = lastValid(total)
225
+ if (idx < 0) throw new Error('no data returned')
226
+ const nominalYoy = idx >= 12 && total[idx - 12] ? ((total[idx] - total[idx - 12]) / total[idx - 12]) * 100 : null
227
+
228
+ // Real YoY: deflate by All groups CPI (8-capital-city avg). CPI publishes
229
+ // a month or two behind HSI, so carry the last known index forward.
230
+ const cpi = parse(cpiJson, ['INDEX'])
231
+ const cpiByPeriod = Object.fromEntries(cpi.periods.map((p, i) => [p, cpi.series['10001']?.[i] ?? null]))
232
+ let last = null
233
+ const aligned = periods.map((p) => { const v = cpiByPeriod[p]; if (v != null) last = v; return last })
234
+ const realYoy =
235
+ idx >= 12 && aligned[idx] && aligned[idx - 12] && total[idx - 12]
236
+ ? (((total[idx] / aligned[idx]) - (total[idx - 12] / aligned[idx - 12])) / (total[idx - 12] / aligned[idx - 12])) * 100
237
+ : null
238
+
239
+ const cats = CATEGORIES
240
+ .map((c) => ({ category: c.label, value_millions: series[`${c.code}|${st}`]?.[idx] ?? null, discretionary: c.discretionary }))
241
+ .filter((c) => c.value_millions != null)
242
+ .sort((a, b) => b.value_millions - a.value_millions)
243
+ const disc = series[`${ANALYTICAL.DISCRETIONARY}|${st}`]?.[idx] ?? null
244
+
245
+ return text({
246
+ source: 'ABS Monthly Household Spending Indicator + Monthly CPI Indicator (live)',
247
+ state,
248
+ period: periods[idx],
249
+ total_spend_millions: total[idx],
250
+ yoy_nominal_pct: nominalYoy == null ? null : Number(nominalYoy.toFixed(2)),
251
+ yoy_real_pct: realYoy == null ? null : Number(realYoy.toFixed(2)),
252
+ top_category: cats[0] ?? null,
253
+ discretionary_share_pct: disc != null && total[idx] ? Number(((disc / total[idx]) * 100).toFixed(1)) : null,
254
+ categories_ranked: cats,
255
+ notes: 'Original series, current prices. Real YoY deflated by All-groups CPI (8-capital-city average).',
256
+ })
257
+ } catch (e) { return errText(e) }
258
+ },
259
+ )
260
+
261
+ server.registerTool(
262
+ 'get_category_breakdown',
263
+ {
264
+ title: 'Spending by category',
265
+ description:
266
+ 'All 9 COICOP spending categories for one month and geography, with $ values, shares of total, ' +
267
+ 'and discretionary flags. Live ABS Monthly Household Spending Indicator data.',
268
+ inputSchema: {
269
+ state: z.enum(STATE_ENUM).default('AUS').describe('Geography (AUS = national)'),
270
+ period: z.string().regex(/^\d{4}-\d{2}$/).optional().describe('Month YYYY-MM; omit for latest'),
271
+ },
272
+ },
273
+ async ({ state, period }) => {
274
+ try {
275
+ const st = stateCode(state)
276
+ const allCodes = [ANALYTICAL.TOTAL, ...CATEGORIES.map((c) => c.code)]
277
+ const { periods, series } = await fetchHsi(allCodes, st, '10')
278
+ const total = series[`${ANALYTICAL.TOTAL}|${st}`] || []
279
+ const idx = period ? periods.indexOf(period) : lastValid(total)
280
+ if (idx < 0) throw new Error(`period ${period} not available (range ${periods[0]}..${periods[periods.length - 1]})`)
281
+ const rows = CATEGORIES
282
+ .map((c) => {
283
+ const v = series[`${c.code}|${st}`]?.[idx] ?? null
284
+ return v == null ? null : {
285
+ category: c.label,
286
+ value_millions: v,
287
+ share_of_total_pct: total[idx] ? Number(((v / total[idx]) * 100).toFixed(1)) : null,
288
+ discretionary: c.discretionary,
289
+ }
290
+ })
291
+ .filter(Boolean)
292
+ .sort((a, b) => b.value_millions - a.value_millions)
293
+ return text({
294
+ source: 'ABS Monthly Household Spending Indicator (live)',
295
+ state, period: periods[idx],
296
+ total_spend_millions: total[idx] ?? null,
297
+ unit: '$ millions, current prices, Original series',
298
+ categories: rows,
299
+ })
300
+ } catch (e) { return errText(e) }
301
+ },
302
+ )
303
+
304
+ server.registerTool(
305
+ 'get_l2_composition',
306
+ {
307
+ title: 'Sub-category (L2) composition',
308
+ description:
309
+ 'Level-2 sub-category detail for one spending category — e.g. Transport splits into Purchase of vehicles / ' +
310
+ 'Operation of vehicles / Transport services. From ABS National Accounts HFCE (quarterly, national, ' +
311
+ `current prices) — a static bundle, ${hfce.release} release, not the live API. ` +
312
+ 'Categories food and clothing have no published L2 split.',
313
+ inputSchema: {
314
+ category: z.enum(['food', 'alcohol_tobacco', 'clothing', 'furnishings', 'health', 'transport', 'recreation', 'hotels_cafes', 'miscellaneous'])
315
+ .describe('L1 category to drill into'),
316
+ quarter: z.string().regex(/^\d{4}-Q[1-4]$/).optional().describe('Quarter like 2026-Q1; omit for latest'),
317
+ },
318
+ },
319
+ async ({ category, quarter }) => {
320
+ try {
321
+ const code = CATEGORY_KEYS[category]
322
+ const m = HFCE_MAP[code]
323
+ const q = quarter ?? hfce.periods[hfce.periods.length - 1]
324
+ const qi = hfce.periods.indexOf(q)
325
+ if (qi < 0) throw new Error(`quarter ${q} not in bundle (range ${hfce.periods[0]}..${hfce.periods[hfce.periods.length - 1]})`)
326
+ const at = (id, i) => hfce.series[id]?.[i] ?? null
327
+ const parent = at(m.parent, qi)
328
+ const yqi = hfce.periods.indexOf(`${Number(q.split('-')[0]) - 1}-${q.split('-')[1]}`)
329
+ const children = m.children.map(([label, id]) => {
330
+ const v = at(id, qi)
331
+ const prev = yqi >= 0 ? at(id, yqi) : null
332
+ return v == null ? null : {
333
+ sub_category: label,
334
+ value_millions: v,
335
+ share_of_parent_pct: parent ? Number(((v / parent) * 100).toFixed(1)) : null,
336
+ yoy_pct: prev ? Number((((v - prev) / prev) * 100).toFixed(2)) : null,
337
+ }
338
+ }).filter(Boolean).sort((a, b) => b.value_millions - a.value_millions)
339
+ return text({
340
+ source: `${hfce.source}, ${hfce.release} release (static bundle — quarterly, national, current prices)`,
341
+ category: LABEL_BY_CODE[code],
342
+ quarter: q,
343
+ parent_total_millions: parent,
344
+ sub_categories: children,
345
+ notes: children.length ? 'yoy_pct compares the same quarter one year earlier.' : 'ABS does not publish an L2 split for this category.',
346
+ })
347
+ } catch (e) { return errText(e) }
348
+ },
349
+ )
350
+
351
+ server.registerTool(
352
+ 'get_cost_of_living',
353
+ {
354
+ title: 'Cost of living by household type',
355
+ description:
356
+ 'ABS Selected Living Cost Indexes (live): quarterly cost-of-living change by household type. ' +
357
+ 'Unlike CPI, LCI includes mortgage interest and consumer credit charges, so employee households ' +
358
+ 'diverge from pensioners when interest rates move. Weighted average of the 8 capital cities.',
359
+ inputSchema: {
360
+ household_type: z.enum([...Object.keys(LCI_TYPES), 'all']).default('all').describe('Household type, or all'),
361
+ measure: z.enum(['yoy', 'index']).default('yoy').describe('yoy = % change vs same quarter last year; index = raw index numbers'),
362
+ start_period: z.string().regex(/^\d{4}-Q[1-4]$/).default('2019-Q1').describe('First quarter, YYYY-Qn'),
363
+ },
364
+ },
365
+ async ({ household_type, measure, start_period }) => {
366
+ try {
367
+ const types = household_type === 'all' ? Object.entries(LCI_TYPES) : [[household_type, LCI_TYPES[household_type]]]
368
+ const key = ['1', '10001', types.map(([, t]) => t.code).join('+'), '50', 'Q'].join('.')
369
+ // YoY needs 4 quarters of run-up before the requested start.
370
+ const fetchStart = measure === 'yoy' ? `${Number(start_period.split('-')[0]) - 1}-${start_period.split('-')[1]}` : start_period
371
+ const { periods, series } = parse(await sdmx(LCI, key, fetchStart), ['HOUSHOLD_TYPE'])
372
+ const startIdx = Math.max(0, periods.indexOf(start_period))
373
+ const out = {}
374
+ for (const [name, t] of types) {
375
+ let vals = series[t.code] || []
376
+ if (measure === 'yoy') vals = toYoY(vals, 4)
377
+ out[t.label] = Object.fromEntries(
378
+ periods.map((p, i) => [p, vals[i]]).slice(startIdx).filter(([, v]) => v != null),
379
+ )
380
+ }
381
+ return text({
382
+ source: 'ABS Selected Living Cost Indexes (live SDMX API)',
383
+ unit: measure === 'yoy' ? '% change year-on-year' : 'index (all groups)',
384
+ region: 'Weighted average of 8 capital cities',
385
+ series: out,
386
+ notes: 'LCI includes mortgage interest & consumer credit charges (CPI does not).',
387
+ })
388
+ } catch (e) { return errText(e) }
389
+ },
390
+ )
391
+
392
+ // ---- start ------------------------------------------------------------------
393
+
394
+ const transport = new StdioServerTransport()
395
+ await server.connect(transport)
396
+ console.error('abs-household-spending MCP server running on stdio')
package/meta.js ADDED
@@ -0,0 +1,36 @@
1
+ // Self-contained copy of the ABS category/state codes the server needs.
2
+ // Mirrors the fields used from ../src/lib/meta.js in the web app; kept here so
3
+ // the npm package has no dependencies outside its own folder.
4
+
5
+ // The 9 COICOP "Level 1" divisions the ABS publishes for the HSI.
6
+ export const CATEGORIES = [
7
+ { code: '10', label: 'Food', discretionary: false },
8
+ { code: '15', label: 'Alcoholic beverages & tobacco', discretionary: true },
9
+ { code: '20', label: 'Clothing & footwear', discretionary: true },
10
+ { code: '30', label: 'Furnishings & household equipment', discretionary: true },
11
+ { code: '35', label: 'Health', discretionary: false },
12
+ { code: '40', label: 'Transport', discretionary: false },
13
+ { code: '50', label: 'Recreation & culture', discretionary: true },
14
+ { code: '60', label: 'Hotels, cafes & restaurants', discretionary: true },
15
+ { code: '65', label: 'Miscellaneous goods & services', discretionary: false },
16
+ ]
17
+
18
+ // Analytical aggregates (discretionary vs non-discretionary view).
19
+ export const ANALYTICAL = {
20
+ TOTAL: 'TOT',
21
+ DISCRETIONARY: '3',
22
+ NON_DISCRETIONARY: '4',
23
+ }
24
+
25
+ // STATE codelist. `abbr` is the friendly input; `code` is the ABS dimension value.
26
+ export const STATES = [
27
+ { code: 'AUS', abbr: 'AUS', label: 'Australia' },
28
+ { code: '1', abbr: 'NSW', label: 'New South Wales' },
29
+ { code: '2', abbr: 'VIC', label: 'Victoria' },
30
+ { code: '3', abbr: 'QLD', label: 'Queensland' },
31
+ { code: '4', abbr: 'SA', label: 'South Australia' },
32
+ { code: '5', abbr: 'WA', label: 'Western Australia' },
33
+ { code: '6', abbr: 'TAS', label: 'Tasmania' },
34
+ { code: '7', abbr: 'NT', label: 'Northern Territory' },
35
+ { code: '8', abbr: 'ACT', label: 'Australian Capital Territory' },
36
+ ]
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "abs-spend-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Australian household spending data — ABS Monthly Household Spending Indicator, CPI, Living Cost Indexes, and National Accounts sub-category detail.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "claude",
9
+ "abs",
10
+ "australia",
11
+ "household-spending",
12
+ "economics",
13
+ "sdmx"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Sathish Kannan",
17
+ "type": "module",
18
+ "bin": {
19
+ "abs-spend-mcp": "index.js"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "meta.js",
24
+ "hfce.json",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/skannan02/australian-household-spending.git",
33
+ "directory": "mcp-server"
34
+ },
35
+ "homepage": "https://github.com/skannan02/australian-household-spending/tree/main/mcp-server#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/skannan02/australian-household-spending/issues"
38
+ },
39
+ "scripts": {
40
+ "start": "node index.js",
41
+ "test": "node test.mjs"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.12.0",
45
+ "zod": "^3.24.0"
46
+ }
47
+ }