Skip to content

feat(fred): search_series and get_series - #37

Merged
walkerhughes merged 2 commits into
fred/scaffoldfrom
fred/discovery
Aug 6, 2026
Merged

feat(fred): search_series and get_series#37
walkerhughes merged 2 commits into
fred/scaffoldfrom
fred/discovery

Conversation

@walkerhughes

Copy link
Copy Markdown
Owner

Closes #32. Second of the fred stack (#30). Stacked on #36, so review that first; this PR's diff is against fred/scaffold.

search_series

Three discovery paths, one tool, one output shape:

Supplied Endpoint
query /series/search
release_id /release/series
category_id /category/series

They are one tool because the answer is identical in all three cases: a list of series. Splitting them would make the model choose between tools that return the same thing.

Ordered by popularity by default. FRED's own default is search_rank, which buries UNRATE under hundreds of regional variants.

Filters go through tag_names, not filter_variable

This is the one non-obvious decision here, and the first attempt got it wrong.

FRED's series-list endpoints accept exactly one filter_variable per request. So "monthly and not seasonally adjusted" means pushing one filter to the API and applying the other locally, over a page FRED has already truncated to limit, against a count that describes the API-side filter only. Live, that returned zero results for monthly + NSA unemployment: the top rows FRED returned were all seasonally adjusted, and the local filter removed every one of them. Over-fetching papers over it without fixing it.

tag_names takes several tags at once and FRED applies all of them:

GET /series/search?search_text=unemployment+rate&tag_names=monthly;nsa
-> count 17128, first result UNRATENSA

So the tool maps frequency and seasonal_adjustment onto FRED's own tag vocabulary (/fred/tags?tag_group_id=freq and =seas) and sends them together. No over-fetch, no client-side filtering, no caveat note, and count means what it says. The change deleted matches_filters and _sa_short rather than adding to them.

One trap the tests pin down: frequency="sa" means semiannual and seasonal_adjustment="sa" means adjusted. Same spelling, different tags (semiannual vs sa). Also, FRED's seas group holds only sa and nsa, so "SAAR" resolves to sa, the tag SAAR series actually carry.

get_series

Folds /series, /series/release, /series/categories and /series/tags into one call, fetched concurrently, selected with include=["metadata"|"notes"|"release"|"categories"|"tags"|"all"].

A bad ID among good ones fails only its own entry. FRED's "The series does not exist" never says which series it means, so failing the whole call would leave a model holding three IDs with no idea which to fix:

{"series": [
  {"id": "UNRATE", "units": "Percent", ...},
  {"id": "NOSUCHSERIES", "error": "Bad Request. The series does not exist.",
   "suggestion": "Check this ID with search_series; the others in this call were returned."}
]}

Correction layer

src/schemas.py corrects before it validates. "unrate" and "UNRATE, CPIAUCSL" and ["unrate"] all become ["UNRATE", ...]; "monthly" becomes m; "unadjusted" becomes the nsa tag; include="notes,tags" becomes a list.

series_ids is annotated list[str] | str deliberately. The MCP layer validates against the annotation before the tool body runs, so a strict list[str] turns get_series("UNRATE") into a raw ToolError that never reaches the correction layer, which is the exact failure the correction layer exists to prevent.

Incidental fix: the API key was being logged

Found while smoke-testing against the real API. httpx logs the full request line at INFO, and FRED takes the key as a query parameter, so every call wrote the key to stderr:

INFO HTTP Request: GET https://api.stlouisfed.org/fred/series/search?...&api_key=abcdef...

configure_logging now pins httpx to WARNING before anything can emit. Our own logging records the path only.

Verification

125 tests, 98% coverage, make check clean. Integration tests drive the registered MCP server against a mock FRED built from trimmed real captures, so the shaping is asserted against FRED's shapes rather than invented ones.

Smoke-tested live against the real API:

monthly+NSA:   17128 matches; UNRATENSA, ALURN, PAYNSA
quarterly+SA:   5850 matches; GDPC1, A191RL1Q225SBEA
get_series:     DGS10 | Percent | Daily | H.15 Selected Interest Rates
mixed good/bad: UNRATE ok, NOSUCHSERIES -> guided error, call still succeeded

Discovery, without which every question starts with a guessed series ID.

search_series is one tool with three paths (free-text, release, category) because
the answer is the same in all three: a list of series. Ordered by popularity
rather than FRED's search_rank default, which buries UNRATE under regional
variants.

Filters go through tag_names, not filter_variable. FRED takes exactly one
filter_variable per request, so "monthly and not seasonally adjusted" would mean
applying the second filter locally, over a page FRED had already truncated,
against a count that no longer describes the result. tag_names takes both,
server-side, and count stays true. Verified live: 17,128 matches for monthly+NSA
unemployment, UNRATENSA first.

get_series folds /series, /series/release, /series/categories and /series/tags
into one call via include=[...]. A bad ID among good ones fails only its own
entry, since FRED's "The series does not exist" never says which series it means.

Also fixes a credential leak found while testing: httpx logs the full request URL
at INFO, and FRED takes the API key as a query parameter, so every call wrote the
key to stderr. httpx is pinned to WARNING before anything can emit.
@walkerhughes
walkerhughes merged commit a60cb3e into main Aug 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fred: search_series and get_series

1 participant