From c5eeffa3cf74fbea7c3fa884196ccbe4283fb0f4 Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:20:03 -0700 Subject: [PATCH] docs(fred): README, design doc, marketplace entry (#35) Makes the plugin installable, which is why it comes after all five tools exist rather than before. The design doc's "what the curation buys" table is measured against the live API rather than estimated. The headline row: the fed funds rate over 20 years is 694,388 chars of raw FRED payload and 4,194 from this server, and the shorter one is the better answer, because the exact 20-year min of 0.04 and max of 5.41 are in the summary instead of somewhere in 5,000 rows. Also adds two guards this repo has needed before: that ${CLAUDE_PLUGIN_ROOT} carries no :-default (the default silently wins and the server launches from the user's own directory), and that the marketplace entry's source path resolves to this plugin directory. Version 0.5.0, one minor per PR in the stack, as the plugin-version gate requires. --- .claude-plugin/marketplace.json | 5 + README.md | 2 + plugins/fred/.claude-plugin/plugin.json | 2 +- plugins/fred/README.md | 107 ++++++++++++++ plugins/fred/docs/design.md | 186 ++++++++++++++++++++++++ plugins/fred/tests/unit/test_server.py | 38 +++++ 6 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 plugins/fred/README.md create mode 100644 plugins/fred/docs/design.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 54fb063..3356544 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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", diff --git a/README.md b/README.md index 76350c0..e6ae566 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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/ diff --git a/plugins/fred/.claude-plugin/plugin.json b/plugins/fred/.claude-plugin/plugin.json index 5a6f1d4..2bfc229 100644 --- a/plugins/fred/.claude-plugin/plugin.json +++ b/plugins/fred/.claude-plugin/plugin.json @@ -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" diff --git a/plugins/fred/README.md b/plugins/fred/README.md new file mode 100644 index 0000000..c50ffa5 --- /dev/null +++ b/plugins/fred/README.md @@ -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: . + +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. diff --git a/plugins/fred/docs/design.md b/plugins/fred/docs/design.md new file mode 100644 index 0000000..5eee910 --- /dev/null +++ b/plugins/fred/docs/design.md @@ -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. diff --git a/plugins/fred/tests/unit/test_server.py b/plugins/fred/tests/unit/test_server.py index 3e77d03..0ddedf6 100644 --- a/plugins/fred/tests/unit/test_server.py +++ b/plugins/fred/tests/unit/test_server.py @@ -63,3 +63,41 @@ def test_the_server_reports_the_shipped_version(self): def test_an_unreadable_manifest_does_not_stop_the_server(self, monkeypatch, tmp_path): monkeypatch.setattr("src.server._MANIFEST", tmp_path / "gone.json") assert version() == "" + + def test_the_plugin_root_placeholder_has_no_default_fallback(self): + """``${CLAUDE_PLUGIN_ROOT:-.}`` silently expands to ``.``, not the plugin root. + + The ``:-default`` form is handled by env-var expansion, which does not know + CLAUDE_PLUGIN_ROOT, so the default always wins and the server launches against + the user's own project directory. Only the bare form is substituted by the + plugin loader. This has shipped broken in this repo before. + """ + config = json.loads((ROOT / ".mcp.json").read_text()) + for arg in config["mcpServers"]["fred"]["args"]: + assert ":-" not in arg, f"{arg!r} uses a :-default; CLAUDE_PLUGIN_ROOT must be bare" + + def test_the_config_never_names_a_secret_value(self): + raw = (ROOT / ".mcp.json").read_text() + assert "source .env" not in raw + # The key is passed through by name only; a literal here would be committed. + assert raw.count("FRED_API_KEY") == 2 # the key and its ${...} reference + + +class TestMarketplace: + """The plugin is only installable once it is listed, and only correct if the + listing points at the directory the manifest actually lives in.""" + + MARKETPLACE = ROOT.parents[1] / ".claude-plugin" / "marketplace.json" + + def entry(self) -> dict: + plugins = json.loads(self.MARKETPLACE.read_text())["plugins"] + matches = [p for p in plugins if p["name"] == "fred"] + assert matches, "fred is not listed in the marketplace, so it cannot be installed" + return matches[0] + + def test_the_source_path_resolves_to_this_plugin(self): + source = (self.MARKETPLACE.parent.parent / self.entry()["source"]).resolve() + assert source == ROOT + + def test_the_listing_has_a_description(self): + assert len(self.entry().get("description", "")) > 40