@pipeworx/mcp-fred 0.1.2 → 0.1.3

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The St. Louis Fed's data warehouse: 800,000+ economic time series spanning interest rates, inflation, employment, GDP, money supply, exchange rates, and metro-level indicators. The most authoritative, continuously updated source for US macro and monetary data — used by economists, policymakers, and journalists.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1683+ live data sources.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1704+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
6
6
 
7
7
  ## Why this matters for AI agents
8
8
 
@@ -55,6 +55,45 @@ pipeworx://fred/series/{series_id}/observations
55
55
 
56
56
  Other agents (and `resources/read`) can resolve these to the current value of the series.
57
57
 
58
+ ## ALFRED vintages: "what did this number say on date X"
59
+
60
+ FRED revises most series after first publication (GDP, payrolls, retail sales…).
61
+ `fred_get_series` defaults to the latest revised values — pass one of these to
62
+ ask for an earlier vintage instead:
63
+
64
+ - `as_of: "2020-03-15"` — the data exactly as it stood on that date. Shorthand
65
+ for `realtime_start = realtime_end = as_of`.
66
+ - `realtime_start` / `realtime_end` — an explicit publication window, for the
67
+ rarer case of a custom range rather than a single date.
68
+
69
+ Omitting all three is the default and is unchanged: no `vintage` field, same
70
+ values as before this parameter existed. Passing any of them adds a `vintage`
71
+ object to the response (`{as_of, realtime_start, realtime_end}`) so you can
72
+ tell which vintage you're looking at.
73
+
74
+ ```js
75
+ fred_get_series({ series_id: "GDPC1", as_of: "2024-01-15", limit: 1 })
76
+ // -> the GDP print as FRED had it published on 2024-01-15, before later revisions
77
+ ```
78
+
79
+ Two more tools cover the revision history directly:
80
+
81
+ - **`fred_vintage_dates({ series_id })`** — every date FRED published a new
82
+ vintage of the series (initial release or a later revision).
83
+ - **`fred_revisions({ series_id, observation_date })`** — how ONE observation's
84
+ value changed across every vintage, from `first_release` to `latest`. Use
85
+ this on revision-prone series (GDP `GDPC1`, payrolls `PAYEMS`) to see the
86
+ originally reported number versus today's.
87
+
88
+ ```js
89
+ fred_revisions({ series_id: "GDPC1", observation_date: "2023-10-01" })
90
+ // -> revisions: [{ vintage_date: "2023-10-26", value: "22490.692" }, ...]
91
+ // one entry per ALFRED vintage, oldest first; first_release is the
92
+ // earliest, latest the most recent. Consecutive vintages can repeat the
93
+ // same value — compare first_release.value to latest.value to see
94
+ // whether this observation was ever actually revised.
95
+ ```
96
+
58
97
  ## Reading a `fred_get_series` response
59
98
 
60
99
  | Field | What it is |
@@ -75,7 +114,7 @@ already mean by them.
75
114
 
76
115
  ## Common pitfalls
77
116
 
78
- - **Vintages**: FRED preserves historical "vintages" (data as it was reported at time T). Default tool calls get the latest revised series. Pass `realtime_start` and `realtime_end` for as-of queries.
117
+ - **Vintages**: FRED preserves historical "vintages" (data as it was reported at time T). Default tool calls get the latest revised series. Pass `as_of` (or `realtime_start`/`realtime_end`) to `fred_get_series` for as-of queries, or use `fred_revisions` to see the full history of one observation.
79
118
  - **Frequency aggregation**: `frequency` parameter coerces to a different cadence (e.g., daily → monthly average). Default is the series' native frequency.
80
119
  - **Units transformation**: `units` parameter computes derived series at request time (`pch` for percent change, `pca` for compound annual rate, etc.). Don't compute these client-side; let FRED do it.
81
120
  - **Series renamed**: occasionally the Fed deprecates a series and creates a successor. Old IDs return errors. `fred_search` is the recovery path.
@@ -124,7 +163,7 @@ directly, instead of just this one's:
124
163
  }
125
164
  ```
126
165
 
127
- Both URLs reach the same gateway and the same 1683+ data sources. The
166
+ Both URLs reach the same gateway and the same 1704+ data sources. The
128
167
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
129
168
  reaches all of them from either one.
130
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-fred",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "FRED MCP — Federal Reserve Economic Data (St. Louis Fed)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@cloudflare/workers-types": "^4.20260405.1"
27
27
  },
28
28
  "pipeworx": {
29
- "sourceHash": "v1-bec1e873c36524537ea0d9a959b7ca09dc1a811b5ba15e1b9a438d068ded2b59",
30
- "sourceCommit": "91681e9a4a137fa0051d58e32f38c64b6334af5c"
29
+ "sourceHash": "v1-fa111ff967e8af148bb0034cc421e106d808627f312e3b60c59862685d3c2904",
30
+ "sourceCommit": "eb436cf60f06c9b79701a15cd1670609580544a5"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/fred",
4
4
  "title": "Fred",
5
5
  "description": "FRED MCP — Federal Reserve Economic Data (St. Louis Fed)",
6
- "version": "0.1.2",
6
+ "version": "0.1.3",
7
7
  "websiteUrl": "https://pipeworx.io/packs/fred",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-fred",
package/src/index.ts CHANGED
@@ -678,10 +678,15 @@ function collapse(s: string): string {
678
678
  *
679
679
  * Tools:
680
680
  * - fred_get_series: get observations for a data series (e.g., MORTGAGE30US, HOUST, CSUSHPISA)
681
+ * accepts `as_of` (or explicit realtime_start/realtime_end) for ALFRED "as published on
682
+ * date X" vintage queries; default (no vintage args) behavior is unchanged.
681
683
  * - fred_search: search for series by keyword
682
684
  * - fred_series_info: get metadata about a series
683
685
  * - fred_category: browse FRED categories
684
686
  * - fred_releases: get latest data releases
687
+ * - fred_release_dates: release calendar (past + scheduled)
688
+ * - fred_vintage_dates: every ALFRED vintage (revision) date for a series
689
+ * - fred_revisions: how one observation's value changed across vintages (first release -> latest)
685
690
  */
686
691
 
687
692
 
@@ -765,11 +770,45 @@ const tools: McpToolExport['tools'] = [
765
770
  // a caller who wants a series in chronological order can ask for it
766
771
  // instead of reversing a truncated tail (fleet #718).
767
772
  sort_order: { type: 'string', description: 'Observation order: desc = newest first (default), asc = oldest first.', enum: ['asc', 'desc'] },
773
+ as_of: { type: 'string', description: 'ALFRED vintage query: return the data as it was PUBLISHED on this date (YYYY-MM-DD), before any later revisions. Shorthand for realtime_start=realtime_end=as_of. Omit for the default: today\'s latest-revised values (unchanged from before this parameter existed).' },
774
+ realtime_start: { type: 'string', description: 'ALFRED vintage query: start of the realtime (publication) window, YYYY-MM-DD. Use with realtime_end for a custom vintage range; use `as_of` for the common single-date case. Omit for the default latest-revised behavior.' },
775
+ realtime_end: { type: 'string', description: 'ALFRED vintage query: end of the realtime (publication) window, YYYY-MM-DD. See realtime_start.' },
768
776
  _apiKey: { type: 'string', description: 'FRED API key' },
769
777
  },
770
778
  required: ['series_id', '_apiKey'],
771
779
  },
772
780
  },
781
+ {
782
+ name: 'fred_vintage_dates',
783
+ description:
784
+ 'ALFRED revision history: every date FRED published a new vintage (initial release or a later revision) of a series. Use before fred_revisions to see which dates have a distinct vintage, or pass one of these dates as `as_of`/`realtime_start`+`realtime_end` to fred_get_series to get the data as it stood on that date.',
785
+ summary: 'The ALFRED vintage (revision) dates for one FRED series, from the St. Louis Fed.',
786
+ inputSchema: {
787
+ type: 'object' as const,
788
+ properties: {
789
+ series_id: { type: 'string', description: 'FRED series ID (e.g., "GDPC1", "PAYEMS")' },
790
+ limit: { type: 'number', description: 'Max vintage dates to return (1-10000, default 100)' },
791
+ sort_order: { type: 'string', description: 'asc = oldest vintage first, desc = newest vintage first (default desc)', enum: ['asc', 'desc'] },
792
+ _apiKey: { type: 'string', description: 'FRED API key' },
793
+ },
794
+ required: ['series_id', '_apiKey'],
795
+ },
796
+ },
797
+ {
798
+ name: 'fred_revisions',
799
+ description:
800
+ 'How ONE observation (one date) in a FRED series was revised over time, from first release through every later vintage to the latest. Use on series that get revised after initial publication (e.g. GDP "GDPC1", payrolls "PAYEMS") to see the originally reported value versus the current one. Returns every ALFRED vintage on record for that observation, oldest first — consecutive vintages can repeat the same value, so compare `first_release.value` to `latest.value` (or scan `revisions`) to see whether it was ever actually revised.',
801
+ summary: 'The revision history of one observation in a FRED series, across every ALFRED vintage.',
802
+ inputSchema: {
803
+ type: 'object' as const,
804
+ properties: {
805
+ series_id: { type: 'string', description: 'FRED series ID (e.g., "GDPC1", "PAYEMS")' },
806
+ observation_date: { type: 'string', description: 'The observation date to trace revisions for, YYYY-MM-DD (must match an actual observation date in the series — e.g. the first day of the quarter for quarterly series).' },
807
+ _apiKey: { type: 'string', description: 'FRED API key' },
808
+ },
809
+ required: ['series_id', 'observation_date', '_apiKey'],
810
+ },
811
+ },
773
812
  {
774
813
  name: 'fred_search',
775
814
  description:
@@ -866,6 +905,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
866
905
  return getReleases(key, args);
867
906
  case 'fred_release_dates':
868
907
  return getReleaseDates(key, args);
908
+ case 'fred_vintage_dates':
909
+ return getVintageDates(key, args);
910
+ case 'fred_revisions':
911
+ return getRevisions(key, args);
869
912
  default:
870
913
  throw new Error(`Unknown tool: ${name}`);
871
914
  }
@@ -894,8 +937,23 @@ function normalizeFredFrequency(v: unknown): string | null {
894
937
 
895
938
  // ── Tool implementations ────────────────────────────────────────────────
896
939
 
940
+ // ALFRED vintage args. `as_of` is shorthand for realtime_start=realtime_end=as_of;
941
+ // explicit realtime_start/realtime_end (either or both) take precedence over
942
+ // `as_of` when present, so a caller can set a custom window. Absent all three,
943
+ // this returns {} and callers of getSeries set nothing extra on the request —
944
+ // which is the point: default behavior must stay byte-for-byte unchanged.
945
+ function resolveVintageArgs(args: Record<string, unknown>): { realtimeStart?: string; realtimeEnd?: string; asOf?: string } {
946
+ const realtimeStart = args.realtime_start as string | undefined;
947
+ const realtimeEnd = args.realtime_end as string | undefined;
948
+ const asOf = args.as_of as string | undefined;
949
+ if (realtimeStart || realtimeEnd) return { realtimeStart, realtimeEnd };
950
+ if (asOf) return { realtimeStart: asOf, realtimeEnd: asOf, asOf };
951
+ return {};
952
+ }
953
+
897
954
  async function getSeries(key: string, args: Record<string, unknown>) {
898
955
  const seriesId = args.series_id as string;
956
+ const { realtimeStart, realtimeEnd, asOf } = resolveVintageArgs(args);
899
957
  const params = new URLSearchParams({
900
958
  series_id: seriesId,
901
959
  api_key: key,
@@ -908,6 +966,8 @@ async function getSeries(key: string, args: Record<string, unknown>) {
908
966
  if (f) params.set('frequency', f); // unknown → drop, return native frequency (don't 400)
909
967
  }
910
968
  if (args.units) params.set('units', args.units as string);
969
+ if (realtimeStart) params.set('realtime_start', realtimeStart);
970
+ if (realtimeEnd) params.set('realtime_end', realtimeEnd);
911
971
  // FRED's observations API defaults to sort_order=asc + ALL observations, so a
912
972
  // bare fred_get_series(UNRATE) returned the full series from 1948 OLDEST-first
913
973
  // and ignored the documented `limit: 20` — agents read a decades-old value,
@@ -917,6 +977,8 @@ async function getSeries(key: string, args: Record<string, unknown>) {
917
977
  params.set('limit', String(Math.min(100000, Math.max(1, (args.limit as number) ?? 20))));
918
978
 
919
979
  const infoParams = new URLSearchParams({ series_id: seriesId, api_key: key, file_type: 'json' });
980
+ if (realtimeStart) infoParams.set('realtime_start', realtimeStart);
981
+ if (realtimeEnd) infoParams.set('realtime_end', realtimeEnd);
920
982
  const [data, infoRaw] = await Promise.all([
921
983
  fredFetch(`${BASE}/series/observations?${params}`) as Promise<{
922
984
  realtime_start: string;
@@ -953,6 +1015,8 @@ async function getSeries(key: string, args: Record<string, unknown>) {
953
1015
  if (f) p2.set('frequency', f);
954
1016
  }
955
1017
  if (args.units) p2.set('units', args.units as string);
1018
+ if (realtimeStart) p2.set('realtime_start', realtimeStart);
1019
+ if (realtimeEnd) p2.set('realtime_end', realtimeEnd);
956
1020
  const retry = (await fredFetch(`${BASE}/series/observations?${p2}`)) as typeof data;
957
1021
  if (retry.observations.length > 0) {
958
1022
  obs = retry;
@@ -1013,6 +1077,18 @@ async function getSeries(key: string, args: Record<string, unknown>) {
1013
1077
  ...(infoRaw?.observation_end ? { observation_end: infoRaw.observation_end } : {}),
1014
1078
  ...(args.observation_start ? { requested_start: args.observation_start as string } : {}),
1015
1079
  ...(args.observation_end ? { requested_end: args.observation_end as string } : {}),
1080
+ // Only present when the caller asked for a vintage — absence here (and
1081
+ // everywhere above) is what keeps the no-args response byte-for-byte
1082
+ // identical to before this field existed.
1083
+ ...(realtimeStart || realtimeEnd
1084
+ ? {
1085
+ vintage: {
1086
+ ...(asOf ? { as_of: asOf } : {}),
1087
+ ...(realtimeStart ? { realtime_start: realtimeStart } : {}),
1088
+ ...(realtimeEnd ? { realtime_end: realtimeEnd } : {}),
1089
+ },
1090
+ }
1091
+ : {}),
1016
1092
  observations: rows,
1017
1093
  };
1018
1094
  }
@@ -1207,4 +1283,110 @@ async function getReleaseDates(key: string, args: Record<string, unknown>) {
1207
1283
  };
1208
1284
  }
1209
1285
 
1286
+ async function getVintageDates(key: string, args: Record<string, unknown>) {
1287
+ const seriesId = args.series_id as string;
1288
+ const cap = Math.min(10000, Math.max(1, (args.limit as number) ?? 100));
1289
+ const sortOrder = (args.sort_order as string) === 'asc' ? 'asc' : 'desc';
1290
+ const params = new URLSearchParams({
1291
+ series_id: seriesId,
1292
+ api_key: key,
1293
+ file_type: 'json',
1294
+ sort_order: sortOrder,
1295
+ limit: '10000', // FRED's own cap; we slice to the caller's `limit` ourselves so `truncated` is honest
1296
+ });
1297
+
1298
+ const data = (await fredFetch(`${BASE}/series/vintagedates?${params}`)) as {
1299
+ count: number;
1300
+ vintage_dates: string[];
1301
+ };
1302
+
1303
+ const all = data.vintage_dates ?? [];
1304
+ const returned = all.slice(0, cap);
1305
+ const truncated = returned.length < all.length;
1306
+ return {
1307
+ series_id: seriesId,
1308
+ total_vintages: typeof data.count === 'number' ? data.count : all.length,
1309
+ returned: returned.length,
1310
+ truncated,
1311
+ ...(truncated
1312
+ ? { note: `Showing ${returned.length} of ${all.length} vintage dates (${sortOrder === 'desc' ? 'newest first' : 'oldest first'}); raise \`limit\` for more.` }
1313
+ : {}),
1314
+ vintage_dates: returned,
1315
+ };
1316
+ }
1317
+
1318
+ // FRED's output_type=2 ("Observations by Vintage Date, All Observations")
1319
+ // does NOT return one row per vintage — verified against the live response
1320
+ // (2026-10-07, after shipping a first version that assumed it did and got
1321
+ // `{}` back for every vintage). It returns ONE ROW for the requested
1322
+ // observation_date, with the date in `date` and one EXTRA COLUMN PER VINTAGE,
1323
+ // named `<SERIES_ID>_<YYYYMMDD>` — e.g. `{ date: "2023-07-01",
1324
+ // GDPC1_20231026: "22490.692", GDPC1_20231129: "22475.212", ... }`. A missing
1325
+ // vintage column's value is FRED's usual "." null marker, not absence.
1326
+ const VINTAGE_COLUMN = /^.+_(\d{8})$/;
1327
+
1328
+ function parseVintageRow(row: Record<string, string>): { vintage_date: string; value: string | null }[] {
1329
+ const out: { vintage_date: string; value: string | null }[] = [];
1330
+ for (const [col, raw] of Object.entries(row)) {
1331
+ const m = col.match(VINTAGE_COLUMN);
1332
+ if (!m) continue; // `date` itself, or any non-vintage field FRED adds
1333
+ const ymd = m[1];
1334
+ const vintageDate = `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
1335
+ out.push({ vintage_date: vintageDate, value: raw === '.' ? null : raw });
1336
+ }
1337
+ out.sort((a, b) => a.vintage_date.localeCompare(b.vintage_date));
1338
+ return out;
1339
+ }
1340
+
1341
+ async function getRevisions(key: string, args: Record<string, unknown>) {
1342
+ const seriesId = args.series_id as string;
1343
+ const observationDate = args.observation_date as string;
1344
+ // Pinning observation_start=observation_end=observationDate and spanning the
1345
+ // full realtime range (FRED's documented earliest/latest sentinels) returns
1346
+ // the whole revision history of one observation in a single call — no need
1347
+ // to walk fred_vintage_dates and refetch per date.
1348
+ //
1349
+ // realtime_end must be FRED's documented max sentinel (9999-12-31), NOT
1350
+ // `new Date()`-derived "today" — FRED 400s "Variable realtime_end can not be
1351
+ // after today's date … unless it's equal to the real-time max date 9999-12-31"
1352
+ // whenever the CALLER's UTC day has already rolled past FRED's Central-time
1353
+ // day (reproduced live post-deploy on 2026-10-07: the gateway's UTC day was
1354
+ // 10-07 while FRED still considered it 10-06). The sentinel means "no upper
1355
+ // bound" to FRED, so it also means we never need to recompute it again.
1356
+ const params = new URLSearchParams({
1357
+ series_id: seriesId,
1358
+ api_key: key,
1359
+ file_type: 'json',
1360
+ observation_start: observationDate,
1361
+ observation_end: observationDate,
1362
+ realtime_start: '1776-07-04', // FRED's documented sentinel for "earliest possible"
1363
+ realtime_end: '9999-12-31', // FRED's documented sentinel for "latest possible" — never "today"
1364
+ output_type: '2',
1365
+ });
1366
+
1367
+ const data = (await fredFetch(`${BASE}/series/observations?${params}`)) as {
1368
+ observations: Record<string, string>[];
1369
+ };
1370
+
1371
+ const rows = data.observations ?? [];
1372
+ const row = rows.find((o) => o.date === observationDate) ?? rows[0];
1373
+ const revisions = row ? parseVintageRow(row) : [];
1374
+
1375
+ return {
1376
+ series_id: seriesId,
1377
+ observation_date: observationDate,
1378
+ // The number of ALFRED VINTAGES on record for this observation — NOT how
1379
+ // many times the value actually changed. Consecutive vintages can (and
1380
+ // often do) repeat the same value; compare first_release.value to
1381
+ // latest.value, or scan `revisions`, to see whether it was ever revised.
1382
+ revisions_count: revisions.length,
1383
+ ...(revisions.length === 0
1384
+ ? { note: `No observation found for ${observationDate} on ${seriesId} — confirm the date matches an actual observation (e.g. the first day of the period for quarterly/monthly series), or call fred_get_series first.` }
1385
+ : {}),
1386
+ first_release: revisions[0] ?? null,
1387
+ latest: revisions[revisions.length - 1] ?? null,
1388
+ revisions,
1389
+ };
1390
+ }
1391
+
1210
1392
  export default { tools, callTool, meter: { credits: 5 } } satisfies McpToolExport;
package/src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
9
9
  import pack from './index.js';
10
10
 
11
11
  const server = new Server(
12
- { name: '@pipeworx/mcp-fred', version: '0.1.2' },
12
+ { name: '@pipeworx/mcp-fred', version: '0.1.3' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15