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