Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/fred/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fred",
"version": "0.3.0",
"version": "0.4.0",
"description": "MCP server for the FRED API: economic time series from the St. Louis Fed, with search, aligned multi-series observations, revision history, and the release calendar.",
"author": {
"name": "Walker Hughes"
Expand Down
8 changes: 8 additions & 0 deletions plugins/fred/src/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ def today() -> date:
return date.today()


def days_before(day: date, count: int) -> date:
return date.fromordinal(day.toordinal() - count)


def days_after(day: date, count: int) -> date:
return date.fromordinal(day.toordinal() + count)


def parse(value: str, *, field: str) -> str:
"""Return a YYYY-MM-DD string, or "" for an unset value.

Expand Down
65 changes: 65 additions & 0 deletions plugins/fred/src/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,68 @@ def _range_is_the_right_way_round(self) -> "ObservationArgs":
@property
def units_meaning(self) -> str:
return UNITS_MEANING[self.units]


class RevisionArgs(BaseModel):
"""Arguments for get_revisions."""

series_id: str
observation_date: str = ""
limit: int = Field(default=10, ge=1, le=100)

@model_validator(mode="before")
@classmethod
def _correct(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
data = dict(data)
if isinstance(data.get("series_id"), str):
ids = normalize_series_ids(data["series_id"])
data["series_id"] = ids[0] if ids else ""
if data.get("observation_date") is not None:
data["observation_date"] = dates.parse(data["observation_date"], field="observation_date")
return data

@field_validator("series_id")
@classmethod
def _not_blank(cls, value: str) -> str:
if not value:
raise ValueError("series_id is required; find one with search_series")
return value


class CalendarArgs(BaseModel):
"""Arguments for get_release_calendar."""

start: str = ""
end: str = ""
release_id: int | None = None
limit: int = Field(default=50, ge=1, le=1000)

@model_validator(mode="before")
@classmethod
def _correct(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
data = dict(data)
for field in ("start", "end"):
if data.get(field) is not None:
data[field] = dates.parse(data[field], field=field)
return data

@model_validator(mode="after")
def _default_window(self) -> "CalendarArgs":
# "What just came out and what is next" is the question, so the default window
# straddles today rather than running from the beginning of the record.
today = dates.today()
if not self.start:
object.__setattr__(self, "start", dates.days_before(today, 7).isoformat())
if not self.end:
object.__setattr__(self, "end", dates.days_after(today, 14).isoformat())
if self.start > self.end:
raise ValueError(f"start ({self.start}) is after end ({self.end}); swap them")
return self

@property
def reaches_into_the_future(self) -> bool:
return self.end >= dates.today().isoformat()
58 changes: 58 additions & 0 deletions plugins/fred/src/shaping.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,64 @@ def summarize(pairs: list[tuple[str, Number]]) -> dict:
return summary


# --- revisions ---------------------------------------------------------------------
#
# With output_type=2 FRED returns one column per vintage, named <SERIES>_<YYYYMMDD>:
#
# {"date": "2025-07-01", "GDPC1_20251223": "24024.957", "GDPC1_20260122": "24026.834",
# "GDPC1_20260220": "24026.834", ... nine in total}
#
# Seven of those nine are the same number. A vintage exists for every release of the
# series, not for every change to this observation, so most columns repeat the one
# before. Collapsing them turns nine columns into "first printed as X, revised once to
# Y", which is the actual answer.


def vintage_history(row: dict, series_id: str) -> list[dict]:
"""Collapse a vintage row into the points where the value actually changed."""
vintages: list[tuple[str, Number]] = []
prefix = f"{series_id}_"
for key, raw in row.items():
if not key.startswith(prefix):
continue
stamp = key[len(prefix) :]
if len(stamp) != 8 or not stamp.isdigit():
continue
vintages.append((f"{stamp[:4]}-{stamp[4:6]}-{stamp[6:]}", parse_value(raw)))
vintages.sort()

history: list[dict] = []
for vintage, value in vintages:
if history and history[-1]["value"] == value:
continue
entry: dict[str, Any] = {"vintage": vintage, "value": value}
if history:
previous = history[-1]["value"]
if previous is not None and value is not None:
entry["change"] = round(value - previous, 6)
if previous:
entry["pct_change"] = round((value - previous) / abs(previous) * 100, 4)
history.append(entry)
return history


def revision_rows(initial: list[tuple[str, Number]], current: list[tuple[str, Number]]) -> list[dict]:
"""Join first-printed against latest, one row per observation date."""
latest = dict(current)
rows: list[dict] = []
for date, first in sorted(initial):
if date not in latest:
continue
now = latest[date]
row: dict[str, Any] = {"date": date, "initial": first, "current": now}
if first is not None and now is not None:
row["revision"] = round(now - first, 6)
if first:
row["revision_pct"] = round((now - first) / abs(first) * 100, 4)
rows.append(row)
return rows


def downsample(dates: list[str], columns: dict[str, list[Number]], max_points: int) -> tuple[list, dict, int]:
"""Thin to evenly spaced points, always keeping the first and the last.

Expand Down
190 changes: 188 additions & 2 deletions plugins/fred/src/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@
import httpx
from mcp.server import MCPServer

from . import shaping
from . import dates, shaping
from .client import FredClient
from .errors import guarded_tool
from .schemas import GetSeriesArgs, ObservationArgs, SearchArgs
from .schemas import CalendarArgs, GetSeriesArgs, ObservationArgs, RevisionArgs, SearchArgs

# ALFRED's real-time window. Vintage requests need one that spans the whole record;
# with the default (today to today) FRED answers output_type=2 and 4 with
# "No vintage dates exist for the specified real-time period", which reads like the
# series has no revision history rather than like a missing parameter. Every vintage
# call here sets it, which is most of what get_revisions is for.
_ALL_TIME = {"realtime_start": "1776-07-04", "realtime_end": "9999-12-31"}

_client: FredClient | None = None

Expand Down Expand Up @@ -196,6 +203,129 @@ async def _get_observations(args: ObservationArgs) -> dict:
return result


async def _revisions_for_one_date(args: RevisionArgs) -> dict:
"""The full revision history of a single observation."""
payload = await get_client().get(
"/series/observations",
series_id=args.series_id,
output_type=2, # one column per vintage
observation_start=args.observation_date,
observation_end=args.observation_date,
**_ALL_TIME,
)
rows = payload.get("observations", [])
if not rows:
return {
"series_id": args.series_id,
"observation_date": args.observation_date,
"revisions": [],
"note": "No observation on that date. Check the date against the series' frequency "
"with get_series; quarterly dates are the first day of the quarter.",
}

history = shaping.vintage_history(rows[0], args.series_id)
result: dict[str, object] = {
"series_id": args.series_id,
"observation_date": args.observation_date,
"revisions": history,
"revision_count": max(0, len(history) - 1),
}
if history:
result["initial"] = history[0]
result["current"] = history[-1]
return result


async def _revision_overview(args: RevisionArgs) -> dict:
"""Initial print against current value, across the most recent observations."""
client = get_client()
initial, current, vintages = await asyncio.gather(
client.get(
"/series/observations",
series_id=args.series_id,
output_type=4, # initial release only
limit=args.limit,
sort_order="desc",
**_ALL_TIME,
),
client.get("/series/observations", series_id=args.series_id, limit=args.limit, sort_order="desc"),
client.get("/series/vintagedates", series_id=args.series_id, limit=1, sort_order="desc"),
)

rows = shaping.revision_rows(shaping.observation_pairs(initial), shaping.observation_pairs(current))
vintage_dates = vintages.get("vintage_dates") or []
return {
"series_id": args.series_id,
"observations": rows,
"revised": sum(1 for row in rows if row.get("revision")),
"vintages": {"count": vintages.get("count"), "latest": vintage_dates[0] if vintage_dates else None},
"note": "initial is the number as first published; current is the number today. "
"Pass observation_date for the full revision history of one of these.",
}


async def _fetch_release_dates(args: CalendarArgs, start: str, end: str, future: bool) -> dict:
params: dict[str, object] = {
"realtime_start": start,
"realtime_end": end,
"sort_order": "asc",
"limit": args.limit,
}
# Without this, FRED returns only dates that have already produced data, so the
# whole "what is scheduled next" half of the question silently comes back empty.
if future:
params["include_release_dates_with_no_data"] = True
if args.release_id is not None:
return await get_client().get("/release/dates", release_id=args.release_id, **params)
return await get_client().get("/releases/dates", **params)


def _calendar_rows(payload: dict) -> list[dict]:
rows = []
for entry in payload.get("release_dates", []):
row: dict = {"date": entry.get("date"), "release_id": entry.get("release_id")}
if entry.get("release_name"):
row["release_name"] = entry["release_name"]
rows.append(row)
return rows


async def _release_calendar(args: CalendarArgs) -> dict:
"""Fetch the two halves of the window separately, then report them separately.

One request for the whole window would be truncated by ``limit`` before the split,
and since FRED returns dates ascending, the truncation lands entirely on the
future. That produced "50 released, 0 upcoming" for a window with 150 scheduled
releases in it: a confidently empty answer to half the question being asked.
"""
today = dates.today().isoformat()
tomorrow = dates.days_after(dates.today(), 1).isoformat()

past = _fetch_release_dates(args, args.start, min(args.end, today), future=False) if args.start <= today else None
ahead = (
_fetch_release_dates(args, max(args.start, tomorrow), args.end, future=True) if args.end > today else None
)
release = get_client().get("/release", release_id=args.release_id) if args.release_id is not None else None

pending = [task for task in (past, ahead, release) if task is not None]
done = iter(await asyncio.gather(*pending))
past_payload = next(done) if past is not None else {}
ahead_payload = next(done) if ahead is not None else {}
release_payload = next(done) if release is not None else None

result: dict[str, object] = {
"window": {"start": args.start, "end": args.end},
"today": today,
"released": _calendar_rows(past_payload),
"upcoming": _calendar_rows(ahead_payload),
# FRED's totals for each half, so a limited page is visibly a page.
"totals": {"released": past_payload.get("count", 0), "upcoming": ahead_payload.get("count", 0)},
}
if release_payload is not None:
result["release"] = shaping.trim_release(shaping.first_or_empty(release_payload, "releases"))
return result


def register_all(mcp: MCPServer) -> None:
"""Register every tool on the given MCP server."""

Expand Down Expand Up @@ -320,3 +450,59 @@ async def get_observations(
}
)
return fmt(await _get_observations(args))

@mcp.tool()
@guarded_tool
async def get_revisions(series_id: str, observation_date: str = "", limit: int = 10) -> str:
"""What a number was first reported as, and how it has been revised since.

FRED keeps every vintage of every series (this is ALFRED). Answers questions
like "what did Q3 GDP originally print at" and "does this series get revised
much", which the current values alone cannot.

Two modes:
observation_date set the full revision history of that one data point,
with the repeated vintages collapsed so you see the
changes rather than one column per publication
observation_date omitted first-printed against current for the most recent
observations, which shows the revision pattern

observation_date takes the same forms as elsewhere, and must be an observation
date rather than a publication date: quarterly series are dated to the first
day of the quarter, monthly to the first of the month.

The real-time window this needs is set for you. Asking FRED for vintages
without it fails with a message about no vintage dates existing, which reads
like the series has no history when the parameter is simply missing.
"""
args = RevisionArgs.model_validate(
{"series_id": series_id, "observation_date": observation_date, "limit": limit}
)
if args.observation_date:
return fmt(await _revisions_for_one_date(args))
return fmt(await _revision_overview(args))

@mcp.tool()
@guarded_tool
async def get_release_calendar(
start: str = "",
end: str = "",
release_id: int | None = None,
limit: int = 50,
) -> str:
"""What economic data just came out, and what is scheduled next.

Splits results into "released" and "upcoming" around today, because those are
two different questions and comparing dates to tell them apart is work the
caller should not have to do.

Defaults to the last 7 days and the next 14. start and end take the same forms
as get_observations ("today", "5y", "2026-08", a full date).

release_id narrows to one publication's schedule, e.g. 50 for the Employment
Situation. Release IDs come from this tool or from get_series(include=
["release"]), and feed back into search_series(release_id=...) to list every
series a release publishes.
"""
args = CalendarArgs.model_validate({"start": start, "end": end, "release_id": release_id, "limit": limit})
return fmt(await _release_calendar(args))
Loading
Loading