@pipeworx/mcp-fred 0.1.1 → 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 +42 -3
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +218 -1
- package/src/server.ts +1 -1
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
|
|
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 `
|
|
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
|
|
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.
|
|
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-
|
|
30
|
-
"sourceCommit": "
|
|
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.
|
|
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
|
@@ -452,7 +452,42 @@ async function fetchWithTimeout(
|
|
|
452
452
|
),
|
|
453
453
|
);
|
|
454
454
|
}
|
|
455
|
-
|
|
455
|
+
// Fleet #2382. Everything that isn't a timeout/abort here is a genuine
|
|
456
|
+
// NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
|
|
457
|
+
// Cloudflare's own "Network connection lost." — meaning `fetch()` itself
|
|
458
|
+
// threw and no HTTP response of any kind was ever received. Until this fix
|
|
459
|
+
// that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
|
|
460
|
+
// (or the Workers-runtime equivalent) names no upstream, carries no class
|
|
461
|
+
// token, and reads exactly like a defect in OUR code — because it says
|
|
462
|
+
// nothing about the call at all. It landed in `error`, the tier that means
|
|
463
|
+
// "Pipeworx has a defect", for every one of the (at the time of writing)
|
|
464
|
+
// ~470 packs that call this helper directly with no wrapper of their own.
|
|
465
|
+
//
|
|
466
|
+
// `dexscreener` hit this independently (fleet #1579) and fixed it with a
|
|
467
|
+
// bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
|
|
468
|
+
// but only covers one pack; every other caller of this shared helper still
|
|
469
|
+
// leaked the raw exception. Moving the same fix HERE — the one place that
|
|
470
|
+
// already carries the timeout case — covers every pack that uses
|
|
471
|
+
// `fetchWithTimeout` without a wrapper, for free, and without widening
|
|
472
|
+
// `classifyToolError`'s regex list: the fix is giving the message a proper
|
|
473
|
+
// `upstream_down:` token at the point the two facts (no response was ever
|
|
474
|
+
// received, and which host we were trying to reach) are actually in hand,
|
|
475
|
+
// not teaching the classifier to guess from prose after the fact.
|
|
476
|
+
//
|
|
477
|
+
// Safe on the same grounds as the timeout branch above: no argument a
|
|
478
|
+
// caller passes can make `fetch()` itself throw a connection-level error,
|
|
479
|
+
// so this is always an availability failure, never a caller mistake. Same
|
|
480
|
+
// `markInternalOrigin` treatment — an origin we run that never answered is
|
|
481
|
+
// still ours, not a third party's outage.
|
|
482
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
483
|
+
throw new Error(
|
|
484
|
+
markInternalOrigin(
|
|
485
|
+
`upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
|
|
486
|
+
`No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
|
|
487
|
+
'are valid — do not re-check them on the strength of this error. Retry shortly.',
|
|
488
|
+
url,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
456
491
|
}
|
|
457
492
|
}
|
|
458
493
|
|
|
@@ -643,10 +678,15 @@ function collapse(s: string): string {
|
|
|
643
678
|
*
|
|
644
679
|
* Tools:
|
|
645
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.
|
|
646
683
|
* - fred_search: search for series by keyword
|
|
647
684
|
* - fred_series_info: get metadata about a series
|
|
648
685
|
* - fred_category: browse FRED categories
|
|
649
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)
|
|
650
690
|
*/
|
|
651
691
|
|
|
652
692
|
|
|
@@ -730,11 +770,45 @@ const tools: McpToolExport['tools'] = [
|
|
|
730
770
|
// a caller who wants a series in chronological order can ask for it
|
|
731
771
|
// instead of reversing a truncated tail (fleet #718).
|
|
732
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.' },
|
|
733
776
|
_apiKey: { type: 'string', description: 'FRED API key' },
|
|
734
777
|
},
|
|
735
778
|
required: ['series_id', '_apiKey'],
|
|
736
779
|
},
|
|
737
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
|
+
},
|
|
738
812
|
{
|
|
739
813
|
name: 'fred_search',
|
|
740
814
|
description:
|
|
@@ -831,6 +905,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
831
905
|
return getReleases(key, args);
|
|
832
906
|
case 'fred_release_dates':
|
|
833
907
|
return getReleaseDates(key, args);
|
|
908
|
+
case 'fred_vintage_dates':
|
|
909
|
+
return getVintageDates(key, args);
|
|
910
|
+
case 'fred_revisions':
|
|
911
|
+
return getRevisions(key, args);
|
|
834
912
|
default:
|
|
835
913
|
throw new Error(`Unknown tool: ${name}`);
|
|
836
914
|
}
|
|
@@ -859,8 +937,23 @@ function normalizeFredFrequency(v: unknown): string | null {
|
|
|
859
937
|
|
|
860
938
|
// ── Tool implementations ────────────────────────────────────────────────
|
|
861
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
|
+
|
|
862
954
|
async function getSeries(key: string, args: Record<string, unknown>) {
|
|
863
955
|
const seriesId = args.series_id as string;
|
|
956
|
+
const { realtimeStart, realtimeEnd, asOf } = resolveVintageArgs(args);
|
|
864
957
|
const params = new URLSearchParams({
|
|
865
958
|
series_id: seriesId,
|
|
866
959
|
api_key: key,
|
|
@@ -873,6 +966,8 @@ async function getSeries(key: string, args: Record<string, unknown>) {
|
|
|
873
966
|
if (f) params.set('frequency', f); // unknown → drop, return native frequency (don't 400)
|
|
874
967
|
}
|
|
875
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);
|
|
876
971
|
// FRED's observations API defaults to sort_order=asc + ALL observations, so a
|
|
877
972
|
// bare fred_get_series(UNRATE) returned the full series from 1948 OLDEST-first
|
|
878
973
|
// and ignored the documented `limit: 20` — agents read a decades-old value,
|
|
@@ -882,6 +977,8 @@ async function getSeries(key: string, args: Record<string, unknown>) {
|
|
|
882
977
|
params.set('limit', String(Math.min(100000, Math.max(1, (args.limit as number) ?? 20))));
|
|
883
978
|
|
|
884
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);
|
|
885
982
|
const [data, infoRaw] = await Promise.all([
|
|
886
983
|
fredFetch(`${BASE}/series/observations?${params}`) as Promise<{
|
|
887
984
|
realtime_start: string;
|
|
@@ -918,6 +1015,8 @@ async function getSeries(key: string, args: Record<string, unknown>) {
|
|
|
918
1015
|
if (f) p2.set('frequency', f);
|
|
919
1016
|
}
|
|
920
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);
|
|
921
1020
|
const retry = (await fredFetch(`${BASE}/series/observations?${p2}`)) as typeof data;
|
|
922
1021
|
if (retry.observations.length > 0) {
|
|
923
1022
|
obs = retry;
|
|
@@ -978,6 +1077,18 @@ async function getSeries(key: string, args: Record<string, unknown>) {
|
|
|
978
1077
|
...(infoRaw?.observation_end ? { observation_end: infoRaw.observation_end } : {}),
|
|
979
1078
|
...(args.observation_start ? { requested_start: args.observation_start as string } : {}),
|
|
980
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
|
+
: {}),
|
|
981
1092
|
observations: rows,
|
|
982
1093
|
};
|
|
983
1094
|
}
|
|
@@ -1172,4 +1283,110 @@ async function getReleaseDates(key: string, args: Record<string, unknown>) {
|
|
|
1172
1283
|
};
|
|
1173
1284
|
}
|
|
1174
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
|
+
|
|
1175
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.
|
|
12
|
+
{ name: '@pipeworx/mcp-fred', version: '0.1.3' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|