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
5 changes: 5 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
"url": "https://github.com/walkerhughes"
},
"plugins": [
{
"name": "fred",
"source": "./plugins/fred",
"description": "Economic data from the St. Louis Fed's FRED API: search 800,000 series, pull several at once onto one date index with transformations like year-over-year applied server-side, see what a number was first reported as before revision, and check the release calendar."
},
{
"name": "harbor-hub",
"source": "./plugins/harbor-hub",
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Formerly `walkerhughes/mcps`, back when it only held MCP servers.

| Server | What it connects to | Plugin |
|--------|---------------------|--------|
| [`fred`](plugins/fred/) | The [FRED API](https://fred.stlouisfed.org/docs/api/fred/): the St. Louis Fed's economic time series, plus revision history and the release calendar (5 tools). | yes |
| [`harbor-hub`](plugins/harbor-hub/) | The [Harbor](https://www.harborframework.com) hub: evaluation jobs, trials, uploads, and published packages. | yes |
| [`tastytrade`](plugins/tastytrade/) | The [TastyTrade Open API](https://developer.tastytrade.com/getting-started/): brokerage account, market data, and order management (12 tools). | not yet |

Expand Down Expand Up @@ -55,6 +56,7 @@ MCP server plugins require [`uv`](https://docs.astral.sh/uv/) on your PATH. The
claude/
├── .claude-plugin/ # marketplace manifest
└── plugins/
├── fred/
├── harbor-hub/
├── persona/
└── tastytrade/
Expand Down
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.4.0",
"version": "0.5.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
107 changes: 107 additions & 0 deletions plugins/fred/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# fred

An MCP server for the [FRED API](https://fred.stlouisfed.org/docs/api/fred/), the Federal Reserve Bank of St. Louis's economic data service: around 800,000 time series covering prices, employment, output, rates, and trade.

Five tools rather than thirty-one endpoint wrappers. See [docs/design.md](docs/design.md) for why, and for what the curation measurably buys.

## Install

```
/plugin marketplace add walkerhughes/claude
```

```
/plugin install fred
```

The non-interactive equivalents, which are also the only way to move an existing install to a new version:

```bash
claude plugin marketplace update walkerhughes && claude plugin update fred@walkerhughes
```

Needs [`uv`](https://docs.astral.sh/uv/) on your PATH. The first launch builds the server's environment, so give it a moment before the tools appear. A plugin update needs a Claude Code restart, not just an `/mcp` reconnect.

## Credentials

A FRED API key is free, takes a minute, and needs no card: <https://fredaccount.stlouisfed.org/apikeys>.

Put it in either place. The environment wins if both are set.

```bash
export FRED_API_KEY="your32characterlowercasealnumkey"
```

```bash
mkdir -p ~/.fred-mcp && printf '{"api_key": "%s"}\n' "$FRED_API_KEY" > ~/.fred-mcp/credentials.json
```

The key's shape is checked before any request, so a key pasted with a stray quote or capital says so instead of coming back as FRED's message about "the value for variable api_key". The key is never written to a log or an error message.

The API is read-only. There is nothing here that can change your data or spend your money.

## Tools

### `search_series(query, release_id, category_id, limit, frequency, seasonal_adjustment, order_by)`

Find series. Start here: FRED names things `CPIAUCSL` and `DFF`, so guessing does not work.

Supply exactly one of `query` (free text), `release_id` (everything in a publication), or `category_id` (everything in a category). All three return the same shape.

Ordered by popularity, so the canonical series comes first. FRED's own default buries `UNRATE` under hundreds of regional variants.

`frequency` takes words or codes (`"monthly"`, `"m"`, `"quarterly"`, `"annual"`). `seasonal_adjustment` takes `"SA"`, `"NSA"`, `"unadjusted"`, or the full phrase.

### `get_series(series_ids, include)`

What a series measures, in what units, at what frequency, seasonally adjusted or not, covering which period, last updated when. The questions that decide whether a number means what you think it does.

`include` selects how much: `metadata` (default), `notes`, `release`, `categories`, `tags`, or `all`. Up to 20 series per call, and a bad ID among good ones fails only its own entry.

### `get_observations(series_ids, start, end, units, frequency, aggregation_method, max_points)`

The numbers. Several series come back on one shared date index, so a comparison is one call:

```json
{"dates": ["2025-01-01", ...],
"values": {"UNRATE": [4.0, ...], "CPIAUCSL": [2.99, ...]},
"summary": {"UNRATE": {"latest": 4.3, "min": 3.4, "max": 14.8, ...}}}
```

`units` transforms server-side, so do not do the arithmetic yourself: `"yoy"` for year-over-year percent change, `"percent change"`, `"change"`, `"annualized"`, `"level"`.

`start` and `end` take `YYYY-MM-DD`, a year (`"2020"`), a year-month (`"2020-01"`), a span back from today (`"5y"`, `"18 months"`), `"ytd"`, or `"today"`.

Every series gets a summary computed over **all** its observations; only the point list is thinned to `max_points`. Twenty years of daily fed funds returns 120 points and still reports the true 20-year minimum and maximum.

### `get_revisions(series_id, observation_date, limit)`

What a number was first reported as, and how it has been revised since.

With `observation_date`, the full revision history of that data point, with repeated vintages collapsed so you see the changes rather than one column per publication. Without one, first-printed against current across recent observations.

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.

### `get_release_calendar(start, end, release_id, limit)`

What economic data just came out, and what is scheduled next, split around today. Defaults to the last 7 days and the next 14.

`release_id` narrows to one publication and closes the discovery loop: this tool gives you release 50, and `search_series(release_id=50)` lists every series the Employment Situation publishes.

## Development

```bash
uv sync
make check # lint, typecheck, unit tests
make test # everything
make coverage # with a report, 80% floor
```

Integration tests drive the registered MCP server against a mock FRED built from trimmed real captures. No network and no API key, so the whole suite runs anywhere.

## Not here

- **Maps / GeoFRED.** A different product with a different shape.
- **Tag and category tree browsing.** `search_series` covers the reachable ground; the tree is a UI affordance.
- **Sources.** Metadata about metadata.
- **A response cache.** FRED allows 120 requests a minute and the data moves slowly, so nothing is under pressure. See the design doc's deferred work.
186 changes: 186 additions & 0 deletions plugins/fred/docs/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# FRED MCP server design

The design follows the Honeycomb MCP write-up
[*"MCP, Easy as 1-2-3?"*](https://www.honeycomb.io/blog/mcp-easy-as-1-2-3): a small set of tools
shaped for a model rather than one wrapper per REST endpoint. FRED has 31 endpoints, and wrapping
each one produces a server that is complete and unusable.

- **Observations are mostly padding.** Every observation arrives as
`{"realtime_start":"2026-08-05","realtime_end":"2026-08-05","date":"2025-01-01","value":"2.99098"}`.
On a normal request the two realtime fields hold the same value on every row, and the key names
repeat once per observation. Twenty years of a daily series is about 5,000 of those.
- **The vocabulary is not guessable.** Year-over-year percent change is `units=pc1`. Monthly
aggregation is `frequency=m`. Initial-release-only is `output_type=4` *and* requires
`realtime_start=1776-07-04`. A model that guesses `units=yoy` gets a 400 naming a variable, and
retries with something worse.
- **Series IDs are opaque.** `CPIAUCSL`, `DFF`, `GDPC1`. Without discovery shaped for a model,
every question starts with a failed guess.
- **The common question is answered badly.** "Compare unemployment and inflation" is N calls
returning N separately-dated lists that the model has to join itself.

So: curated tools, argument correction ahead of validation, errors that carry a fix, columnar
responses, and summaries computed on the server.

## Tools

| Tool | Covers | Endpoints folded in |
|---|---|---|
| `search_series(query, release_id, category_id, ...)` | Discovery. One output shape, three paths. Ordered by popularity. | `/series/search`, `/release/series`, `/category/series` |
| `get_series(series_ids, include=[...])` | One snapshot per series: metadata, notes, release, categories, tags. | `/series`, `/series/release`, `/series/categories`, `/series/tags` |
| `get_observations(series_ids, start, end, units, frequency, ...)` | 1..N series on one date index, downsampled, with a per-series summary. | `/series/observations` |
| `get_revisions(series_id, observation_date)` | What a number was first reported as, and every revision since. | `/series/observations` with `output_type=2`/`4`, `/series/vintagedates` |
| `get_release_calendar(start, end, release_id)` | What just came out and what is next. | `/releases/dates`, `/release/dates`, `/release` |

Deliberately out: the Maps/GeoFRED endpoints (a different product), tag and category tree
browsing (search covers the reachable ground), and `/sources` (metadata about metadata).

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

## Architecture

FastMCP's successor `MCPServer` (mcp 2.x), Python 3.13, httpx, stdio, and Pydantic.

```
src/
server.py # MCPServer setup, instructions, version from plugin.json
client.py # key resolution, request signing, 429/5xx backoff
schemas.py # Pydantic argument schemas: correct, then validate
dates.py # relative and partial dates -> YYYY-MM-DD
shaping.py # trimming, columnar alignment, summaries, downsampling, vintages
errors.py # @guarded_tool
tools.py # the five tools
log.py # stderr logging (stdout is the MCP channel)
```

Smaller than the tastytrade server because there is no token refresh, no write path to gate, and
no local pagination to serve.

- The Pydantic schemas do two jobs, in the order that matters. A `model_validator(mode="before")`
rewrites the arguments a model plausibly writes into the ones FRED accepts, and a `mode="after"`
validator rejects what is left with a message that names the fix.
- `errors.py` wraps every tool with `@guarded_tool`, returning `{error, suggestions}` and never a
traceback. FRED puts the real reason in the body rather than the status, so a missing series and
a bad `units` code are both HTTP 400 and only `error_message` tells them apart. The suggestions
key off the body.
- `log.py` writes to stderr, since stdout is the JSON-RPC channel. It also pins httpx to WARNING:
httpx logs the full request line at INFO and FRED takes the API key as a **query parameter**, so
at INFO the key is written to stderr on every call.

## The correction layer

Not politeness. Every input below is unambiguous, and every one of them is a 400 without this.

**Units.** The most-asked transformation in economics is year-over-year percent change, and FRED
spells it `pc1`.

| A model writes | Sent |
|---|---|
| `yoy`, `year over year`, `percent change from a year ago`, `inflation` | `pc1` |
| `percent change`, `pct_change`, `mom` | `pch` |
| `change`, `diff` | `chg` |
| `change from a year ago` | `ch1` |
| `annualized`, `saar` | `pca` |
| `level`, `levels`, `raw`, `none`, `""` | `lin` |
| `natural log`, `ln` | `log` |

The response echoes `units` with a plain-English `units_meaning`, so the numbers are not left to
be interpreted.

**Dates.** FRED wants `YYYY-MM-DD`. Also accepted: `2020`, `2020-01`, `5y`, `18 months`,
`last 10 years`, `ytd`, `today`. Calendar arithmetic is done without a dependency and clamps
correctly: three months before 31 May is 28 February, and a year before 29 Feb 2024 is 28 Feb
2023. An impossible date and a backwards range are caught locally rather than at the API.

**Frequency and seasonal adjustment.** `monthly` becomes `m`, `unadjusted` becomes the `nsa` tag.
Note that `frequency="sa"` means *semiannual* while `seasonal_adjustment="sa"` means *adjusted*:
same spelling, different tags, and a test pins it.

**Series IDs.** `unrate`, `"UNRATE, CPIAUCSL"`, `"UNRATE CPIAUCSL"` and `["unrate"]` all become
`["UNRATE", ...]`. FRED IDs are uppercase and it will not meet you halfway.

The argument annotations on `series_ids` and `include` are `list[str] | str` on purpose. 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.

## Response shaping

**Columnar observations.** `{"dates": [...], "values": {"UNRATE": [...]}}` instead of N lists of
four-field objects. Several series share one index, so comparison arrives joined. Where a
quarterly series has no monthly observation the value is `null`, never forward-filled: filling
would invent numbers FRED did not publish. `"."`, FRED's missing marker, becomes `null`, and value
strings become floats.

**Summary before downsampling.** `latest`, `latest_date`, `prior`, `change`, `pct_change`, `min`,
`max`, `mean`, `count` and `observations` are computed over **every** observation. Only the
returned point list is thinned, to evenly spaced samples that always keep the first and last. The
ordering is the whole trick: a summary computed after thinning would quietly report the sample's
range as the series' range, and nothing in the output would look wrong. `points` reports `total`
and `dropped`, so a thinned series is never mistaken for a complete one.

The daily test fixture is built from explicit knots that put the peak and the trough in the
*interior* of the series. With the extremes at the endpoints, which downsampling always keeps, the
test could not tell the two orderings apart.

**Collapsed vintages.** `output_type=2` returns one column per vintage, but a vintage exists for
every publication of the series, not for every change to the observation being asked about. Q3
2025 GDP has nine vintages and one actual revision, so `get_revisions` returns two entries.

**Trimmed series objects.** A FRED series object carries 16 fields, four of which are shorthand
duplicates of another four and one of which (`notes`) runs to paragraphs. `search_series` drops
notes entirely: ten results at full fidelity is a few thousand tokens spent to choose one ID, and
`get_series` serves notes on demand.

## What the curation buys

Measured against the live API, not estimated. Response sizes in characters.

| Question | Endpoint-wrapper baseline | This server |
|---|---|---|
| Fed funds rate now, and its 20-year range | 1 call, **694,388** chars (~5,000 observations; the model finds the extremes) | 1 call, **4,194** chars: 120 points, true min and max in the summary |
| CPI year-over-year vs unemployment since 2015 | 2 calls, **27,758** chars, two unaligned lists to join | 1 call, **6,777** chars, one date index, both summaries |
| Find the unemployment rate series | 1 call, **8,201** chars for 5 results (notes included) | 1 call, **1,835** chars, popularity-ordered |
| What did Q3 2025 GDP first print at? | 1 call that **400s**, then guesswork about the real-time window | 1 call, 9 vintages collapsed to 1 revision |

The DFF row is the headline: 0.6% of the payload, and a *better* answer, because the exact
20-year minimum of 0.04 and maximum of 5.41 are in the summary rather than somewhere in 5,000
rows the model has to scan.

## Errors

Every failure is `{error, suggestions}`. A missing series names `search_series` and reminds that
IDs are uppercase. A vintage failure names `get_revisions`. A frequency failure explains that a
series can only be aggregated to a coarser interval. A rejected key explains where the key is read
from and says never to ask the user to paste one into the chat.

Invalid units, unreadable dates, reversed ranges, and unknown `include` values never reach the API
at all; the tests assert that no request was made.

Per-series isolation: a bad ID among good ones costs only its own entry, because FRED's
`"The series does not exist"` never says *which* series it means, and failing the whole call would
leave a model holding three IDs with no idea which to fix.

## Evals

Unit-level misuse tests (`tests/unit/test_schemas.py`, `test_dates.py`) feed realistic model
mistakes through correction and validation and assert the corrections and the suggestion-bearing
errors. 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. All of
it runs in CI with no key and no network.

There is no Harbor agent-loop benchmark, unlike the tastytrade server. That one exists because
order placement makes a wrong answer expensive; FRED is read-only. A benchmark is worth adding once
the tool surface has settled, and it would measure the table above at the agent loop rather than at
the payload.

## Deferred work

- **A response cache.** FRED allows 120 requests a minute and the data moves slowly, so nothing is
under pressure. Worth revisiting if an agent starts re-fetching the same series within a turn.
- **Category and tag tree browsing.** `search_series(category_id=...)` reaches any category whose
ID is known; walking the tree to find one is not covered.
- **Maps/GeoFRED.** Regional data by shape rather than by series.
- **Paging.** Every tool returns a bounded page with FRED's own total. No cursor, because no
question so far has needed the second page.
Loading
Loading