diff --git a/plugins/fred/.claude-plugin/plugin.json b/plugins/fred/.claude-plugin/plugin.json index 925e309..2496455 100644 --- a/plugins/fred/.claude-plugin/plugin.json +++ b/plugins/fred/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "fred", - "version": "0.1.0", + "version": "0.2.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/src/log.py b/plugins/fred/src/log.py index cf6af40..2f2d105 100644 --- a/plugins/fred/src/log.py +++ b/plugins/fred/src/log.py @@ -13,6 +13,11 @@ def configure_logging(level: str | None = None) -> None: """Attach a single stderr handler at the configured level. Idempotent.""" + # httpx logs every 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 single call. Ours + # logs the path only. Raise httpx to WARNING before anything can emit. + logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(LOGGER_NAME) logger.setLevel((level or os.environ.get("FRED_LOG_LEVEL") or "INFO").upper()) logger.propagate = False diff --git a/plugins/fred/src/schemas.py b/plugins/fred/src/schemas.py new file mode 100644 index 0000000..356cdcd --- /dev/null +++ b/plugins/fred/src/schemas.py @@ -0,0 +1,233 @@ +"""Argument schemas: correct first, then validate. + +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. + +Correction is not politeness. FRED's vocabulary is not guessable: series IDs are +case-sensitive and uppercase, frequencies are single letters. A model that writes +``frequency="monthly"`` and gets a 400 will retry with something worse, and two rounds +later it has simplified the question into one it can answer badly. +""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator, model_validator + +MAX_SERIES_PER_CALL = 20 + +# FRED's frequency codes, and the words a model writes instead. +FREQUENCY_ALIASES: dict[str, str] = { + "d": "d", "daily": "d", "day": "d", + "w": "w", "weekly": "w", "week": "w", + "bw": "bw", "biweekly": "bw", "bi-weekly": "bw", "biweek": "bw", + "m": "m", "monthly": "m", "month": "m", "mo": "m", + "q": "q", "quarterly": "q", "quarter": "q", + "sa": "sa", "semiannual": "sa", "semiannually": "sa", "semi-annual": "sa", "biannual": "sa", + "a": "a", "annual": "a", "annually": "a", "yearly": "a", "year": "a", "y": "a", +} # fmt: skip + +# Both filters are applied as FRED tags rather than as filter_variable/filter_value. +# A request carries exactly one filter_variable, so filtering on frequency *and* +# seasonal adjustment that way means doing one of them locally, over a page FRED +# already truncated, against a count that no longer means anything. tag_names takes +# several at once, server-side, and the count stays true. +# +# These are FRED's own tag names, from /fred/tags?tag_group_id=freq and =seas. +FREQUENCY_TAGS: dict[str, str] = { + "d": "daily", + "w": "weekly", + "bw": "biweekly", + "m": "monthly", + "q": "quarterly", + "sa": "semiannual", + "a": "annual", +} + +# The seas group holds only these two. Series marked SAAR carry the "sa" tag, so +# "saar" resolves there rather than to a tag that does not exist. +SEASONAL_TAGS: dict[str, str] = { + "sa": "sa", + "seasonally adjusted": "sa", + "seasonal": "sa", + "adjusted": "sa", + "saar": "sa", + "seasonally adjusted annual rate": "sa", + "nsa": "nsa", + "not seasonally adjusted": "nsa", + "unadjusted": "nsa", + "raw": "nsa", +} + +SERIES_ORDER_BY = { + "popularity", + "group_popularity", + "search_rank", + "series_id", + "title", + "units", + "frequency", + "seasonal_adjustment", + "last_updated", + "observation_start", + "observation_end", +} + +SERIES_INCLUDES = ("metadata", "notes", "release", "categories", "tags") + + +def normalize_series_ids(value: Any) -> Any: + """Accept the several ways a model writes a list of series IDs. + + ``"unrate"``, ``"UNRATE, CPIAUCSL"``, ``"UNRATE CPIAUCSL"`` and ``["unrate"]`` all + become ``["UNRATE", ...]``. FRED IDs are uppercase and it will not meet you halfway. + Order is preserved and duplicates are dropped, so asking for the same series twice + does not fetch it twice. + """ + if isinstance(value, str): + value = value.replace(",", " ").split() + if not isinstance(value, (list, tuple)): + return value + + seen: dict[str, None] = {} + for item in value: + if not isinstance(item, str): + return value # let pydantic report the real type error + for part in item.replace(",", " ").split(): + seen.setdefault(part.strip().upper(), None) + return list(seen) + + +def normalize_frequency(value: Any) -> Any: + if isinstance(value, str) and value.strip(): + return FREQUENCY_ALIASES.get(value.strip().lower(), value.strip()) + return value + + +def normalize_seasonal(value: Any) -> Any: + if isinstance(value, str) and value.strip(): + return SEASONAL_TAGS.get(value.strip().lower(), value.strip()) + return value + + +def _as_list(value: Any) -> Any: + """A model asked for a list often sends a bare string or a comma-joined one.""" + if isinstance(value, str): + return [part.strip() for part in value.replace(",", " ").split() if part.strip()] + return value + + +class SearchArgs(BaseModel): + """Arguments for search_series: one of three discovery paths, one output shape.""" + + query: str = "" + release_id: int | None = None + category_id: int | None = None + limit: int = Field(default=10, ge=1, le=100) + frequency: str = "" + seasonal_adjustment: str = "" + order_by: str = "popularity" + + @model_validator(mode="before") + @classmethod + def _correct(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + data = dict(data) + if "frequency" in data: + data["frequency"] = normalize_frequency(data["frequency"]) + if "seasonal_adjustment" in data: + data["seasonal_adjustment"] = normalize_seasonal(data["seasonal_adjustment"]) + if isinstance(data.get("order_by"), str): + data["order_by"] = data["order_by"].strip().lower().replace(" ", "_") or "popularity" + if isinstance(data.get("query"), str): + data["query"] = data["query"].strip() + return data + + @field_validator("frequency") + @classmethod + def _known_frequency(cls, value: str) -> str: + if value and value not in FREQUENCY_TAGS: + raise ValueError(f"unknown frequency {value!r}; use one of {', '.join(FREQUENCY_TAGS)}") + return value + + @field_validator("seasonal_adjustment") + @classmethod + def _known_seasonal(cls, value: str) -> str: + if value and value not in ("sa", "nsa"): + raise ValueError(f"unknown seasonal_adjustment {value!r}; use 'SA', 'NSA', or 'unadjusted'") + return value + + @field_validator("order_by") + @classmethod + def _known_order_by(cls, value: str) -> str: + if value not in SERIES_ORDER_BY: + raise ValueError(f"unknown order_by {value!r}; use one of {', '.join(sorted(SERIES_ORDER_BY))}") + return value + + @model_validator(mode="after") + def _exactly_one_path(self) -> "SearchArgs": + paths = (("query", self.query), ("release_id", self.release_id), ("category_id", self.category_id)) + chosen = [name for name, value in paths if value not in ("", None)] + if not chosen: + raise ValueError( + "supply one of query (free-text search), release_id (every series in a " + "release), or category_id (every series in a category)" + ) + if len(chosen) > 1: + raise ValueError(f"supply only one of query, release_id, category_id; got {' and '.join(chosen)}") + return self + + @property + def tag_names(self) -> str: + """The filters as FRED's semicolon-joined tag list, empty when none are set.""" + tags = [FREQUENCY_TAGS[self.frequency]] if self.frequency else [] + if self.seasonal_adjustment: + tags.append(self.seasonal_adjustment) + return ";".join(tags) + + @property + def path(self) -> str: + if self.query: + return "/series/search" + if self.release_id is not None: + return "/release/series" + return "/category/series" + + +class GetSeriesArgs(BaseModel): + """Arguments for get_series.""" + + series_ids: list[str] = Field(min_length=1, max_length=MAX_SERIES_PER_CALL) + include: list[str] = Field(default_factory=lambda: ["metadata"]) + + @model_validator(mode="before") + @classmethod + def _correct(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + data = dict(data) + if "series_ids" in data: + data["series_ids"] = normalize_series_ids(data["series_ids"]) + if "include" in data and data["include"] is not None: + include = _as_list(data["include"]) + if isinstance(include, list): + lowered = [str(part).strip().lower() for part in include] + if "all" in lowered: + lowered = list(SERIES_INCLUDES) + data["include"] = lowered + return data + + @field_validator("include") + @classmethod + def _known_includes(cls, value: list[str]) -> list[str]: + unknown = [part for part in value if part not in SERIES_INCLUDES] + if unknown: + raise ValueError( + f"unknown include {', '.join(repr(u) for u in unknown)}; " + f"use any of {', '.join(SERIES_INCLUDES)}, or 'all'" + ) + return value + + def wants(self, part: str) -> bool: + return part in self.include diff --git a/plugins/fred/src/shaping.py b/plugins/fred/src/shaping.py new file mode 100644 index 0000000..257a84b --- /dev/null +++ b/plugins/fred/src/shaping.py @@ -0,0 +1,68 @@ +"""Trim FRED payloads to what a model reads. + +A FRED series object carries 16 fields, four of which are shorthand duplicates of +another four, two of which (``realtime_start``, ``realtime_end``) are the same value on +every row of a normal response, and one of which (``notes``) can run to several +paragraphs. Ten search results at full fidelity is a few thousand tokens spent to pick +one ID. +""" + +from typing import Any + +# Kept from a FRED series object, in the order a model reads them: what it is, then +# what the numbers mean, then whether the data is current. +_SERIES_FIELDS = ( + "id", + "title", + "units", + "frequency", + "observation_start", + "observation_end", + "last_updated", + "popularity", +) + + +def trim_series(raw: dict, *, notes: bool = False) -> dict: + """One series object, reduced to the fields worth spending tokens on. + + ``seasonal_adjustment`` is carried as the SA/NSA shorthand, which is unambiguous + and a fifth the length of the phrase. ``notes`` is opt-in: it is the single largest + field and is not what anyone is reading a *list* of series for. + """ + out = {field: raw[field] for field in _SERIES_FIELDS if raw.get(field) is not None} + if raw.get("seasonal_adjustment_short"): + out["seasonal_adjustment"] = raw["seasonal_adjustment_short"] + if notes and raw.get("notes"): + out["notes"] = " ".join(str(raw["notes"]).split()) + return out + + +def trim_release(raw: dict) -> dict: + """A release object: who publishes the series and where to read the press release.""" + out = {"id": raw.get("id"), "name": raw.get("name")} + if raw.get("link"): + out["link"] = raw["link"] + if raw.get("press_release") is not None: + out["press_release"] = raw["press_release"] + return out + + +def trim_category(raw: dict) -> dict: + return {"id": raw.get("id"), "name": raw.get("name")} + + +def tag_names(raw_tags: list[dict]) -> list[str]: + """Tags are only useful as names here; the group, notes, and counts are not read.""" + return [tag["name"] for tag in raw_tags if tag.get("name")] + + +def series_list(payload: dict, *, notes: bool = False) -> list[dict]: + """The ``seriess`` array that /series, /series/search, /release/series and + /category/series all return under the same misspelled key.""" + return [trim_series(item, notes=notes) for item in payload.get("seriess", [])] + + +def first_or_empty(payload: dict, key: str) -> dict[str, Any]: + items = payload.get(key) or [] + return items[0] if items else {} diff --git a/plugins/fred/src/tools.py b/plugins/fred/src/tools.py index b2be9b4..8338026 100644 --- a/plugins/fred/src/tools.py +++ b/plugins/fred/src/tools.py @@ -1,16 +1,27 @@ -"""MCP tools. Each is registered on the FastMCP server by ``register_all``. +"""MCP tools. Each is registered on the server by ``register_all``. -Tools are added over the stack in #30; this module is the seam they attach to. +Tools are task-shaped rather than endpoint-shaped: the unit is a question someone +actually asks, so one call can span several FRED endpoints, and the response carries +what was asked for rather than everything the API knows. """ +import asyncio import json +import httpx from mcp.server import MCPServer +from . import shaping from .client import FredClient +from .errors import guarded_tool +from .schemas import GetSeriesArgs, SearchArgs _client: FredClient | None = None +# Ordering that reads naturally for the field. Popularity descending is the useful +# default; a title sorted from Z is not. +_ASCENDING_ORDERS = {"series_id", "title", "units", "frequency", "seasonal_adjustment", "observation_start"} + def get_client() -> FredClient: """Lazy-init the API client so the key is read at tool time, not import time.""" @@ -31,5 +42,168 @@ def fmt(data: object) -> str: return json.dumps(data, indent=2, default=str) +async def _search_series(args: SearchArgs) -> dict: + """Run one of the three discovery paths and return the shared output shape.""" + client = get_client() + + params: dict[str, object] = { + "limit": args.limit, + "order_by": args.order_by, + "sort_order": "asc" if args.order_by in _ASCENDING_ORDERS else "desc", + } + if args.query: + params["search_text"] = args.query + scope = "search" + elif args.release_id is not None: + params["release_id"] = args.release_id + scope = "release" + else: + params["category_id"] = args.category_id + scope = "category" + + # Filters go through tag_names, which takes several at once and is applied by + # FRED. filter_variable would allow only one per request, leaving the other to be + # applied here against an already-truncated page and a count that no longer + # describes what came back. + if args.tag_names: + params["tag_names"] = args.tag_names + + payload = await client.get(args.path, **params) + series = shaping.series_list(payload) + + result: dict[str, object] = {"scope": scope, "count": payload.get("count")} + filters = { + k: v + for k, v in ( + ("frequency", args.frequency), + ("seasonal_adjustment", args.seasonal_adjustment), + ("order_by", args.order_by), + ) + if v + } + result["filters"] = filters + result["returned"] = len(series) + result["series"] = series + return result + + +async def _fetch_one_series(args: GetSeriesArgs, series_id: str) -> dict: + """Metadata for one series, plus whichever extras were asked for, fetched together. + + A bad ID among good ones fails only its own entry. FRED's "The series does not + exist" does not say which series it means, so failing the whole call would leave a + model with several IDs and no idea which to fix. + """ + client = get_client() + + async def optional(part: str, path: str) -> dict | None: + if not args.wants(part): + return None + return await client.get(path, series_id=series_id) + + try: + meta, release, categories, tags = await asyncio.gather( + client.get("/series", series_id=series_id), + optional("release", "/series/release"), + optional("categories", "/series/categories"), + optional("tags", "/series/tags"), + ) + except httpx.HTTPStatusError as exc: + detail = "" + try: + detail = str(exc.response.json().get("error_message", "")).strip() + except ValueError: + pass + return { + "id": series_id, + "error": " ".join(detail.split()) or f"HTTP {exc.response.status_code}", + "suggestion": "Check this ID with search_series; the others in this call were returned.", + } + + out = shaping.trim_series(shaping.first_or_empty(meta, "seriess"), notes=args.wants("notes")) + out.setdefault("id", series_id) + if release is not None: + out["release"] = shaping.trim_release(shaping.first_or_empty(release, "releases")) + if categories is not None: + out["categories"] = [shaping.trim_category(c) for c in categories.get("categories", [])] + if tags is not None: + out["tags"] = shaping.tag_names(tags.get("tags", [])) + return out + + def register_all(mcp: MCPServer) -> None: """Register every tool on the given MCP server.""" + + @mcp.tool() + @guarded_tool + async def search_series( + query: str = "", + release_id: int | None = None, + category_id: int | None = None, + limit: int = 10, + frequency: str = "", + seasonal_adjustment: str = "", + order_by: str = "popularity", + ) -> str: + """Find FRED series. Start here: series are named by opaque IDs like CPIAUCSL. + + Supply exactly one of: + query free-text search, e.g. "unemployment rate", "10 year treasury" + release_id every series in a release (get IDs from get_release_calendar) + category_id every series in a category (0 is the FRED root) + + Results are ordered by popularity by default, so the canonical series comes + first. FRED's own default buries UNRATE under hundreds of regional variants. + + frequency accepts words or codes: "monthly"/"m", "quarterly"/"q", "annual"/"a", + "daily"/"d", "weekly"/"w", "biweekly"/"bw", "semiannual"/"sa". + seasonal_adjustment accepts "SA", "NSA", "seasonally adjusted", "unadjusted". + + Returns the total match count alongside the page, so you can tell 10-of-12 from + 10-of-53,486. Series notes are omitted here; use get_series for those. + """ + return fmt( + await _search_series( + SearchArgs( + query=query, + release_id=release_id, + category_id=category_id, + limit=limit, + frequency=frequency, + seasonal_adjustment=seasonal_adjustment, + order_by=order_by, + ) + ) + ) + + # series_ids and include are typed as list-or-string 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. The docstring still tells the model a list is the expected form. + @mcp.tool() + @guarded_tool + async def get_series(series_ids: list[str] | str, include: list[str] | str | None = None) -> str: + """Explain what one or more series measure, before charting or comparing them. + + Answers the questions that decide whether a number means what you think: + the units (percent? billions? an index?), the frequency, whether it is + seasonally adjusted, the period it covers, and when it was last updated. + + series_ids is case-insensitive here and up to 20 at a time. + + include controls how much comes back, defaulting to ["metadata"]: + metadata units, frequency, seasonal adjustment, coverage, last update + notes the full definition, including which survey it comes from + release the publication it belongs to, with a link to the press release + categories where it sits in the FRED category tree + tags FRED's tags for the series + Pass "all" for everything. + + A bad ID among good ones fails only its own entry; the rest are still returned. + """ + # model_validate, not the constructor: the before-validator is what accepts a + # bare string, and only this entry point is typed loosely enough to reach it. + args = GetSeriesArgs.model_validate({"series_ids": series_ids, "include": include or ["metadata"]}) + results = await asyncio.gather(*(_fetch_one_series(args, sid) for sid in args.series_ids)) + return fmt({"series": list(results)}) diff --git a/plugins/fred/tests/fixtures/__init__.py b/plugins/fred/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/fred/tests/fixtures/fred_api.py b/plugins/fred/tests/fixtures/fred_api.py new file mode 100644 index 0000000..6898aa3 --- /dev/null +++ b/plugins/fred/tests/fixtures/fred_api.py @@ -0,0 +1,180 @@ +"""A mock FRED API, as an httpx transport. + +Responses are trimmed captures from the real API, so the shapes the shaping layer is +asserted against are FRED's rather than ones invented to match the code. The whole +thing is a routing function because that is all the integration tests need: no server, +no port, no ASGI app. + +Every handler records the query it was called with, which is how the tests check that +the tools send the parameters they claim to (the real-time window on a vintage request, +the filter_variable pairing, the popularity ordering). +""" + +import httpx + +UNRATE = { + "id": "UNRATE", + "realtime_start": "2026-08-05", + "realtime_end": "2026-08-05", + "title": "Unemployment Rate", + "observation_start": "1948-01-01", + "observation_end": "2026-06-01", + "frequency": "Monthly", + "frequency_short": "M", + "units": "Percent", + "units_short": "%", + "seasonal_adjustment": "Seasonally Adjusted", + "seasonal_adjustment_short": "SA", + "last_updated": "2026-07-02 08:31:40-05", + "popularity": 96, + "group_popularity": 96, + "notes": "The unemployment rate represents the number of unemployed as a percentage\r\n\r\nof the labor force.", +} + +CPIAUCSL = { + "id": "CPIAUCSL", + "realtime_start": "2026-08-05", + "realtime_end": "2026-08-05", + "title": "Consumer Price Index for All Urban Consumers: All Items in U.S. City Average", + "observation_start": "1947-01-01", + "observation_end": "2026-06-01", + "frequency": "Monthly", + "frequency_short": "M", + "units": "Index 1982-1984=100", + "units_short": "Index 1982-1984=100", + "seasonal_adjustment": "Seasonally Adjusted", + "seasonal_adjustment_short": "SA", + "last_updated": "2026-07-15 07:41:02-05", + "popularity": 92, + "group_popularity": 92, + "notes": "The CPI measures the average change in prices.", +} + +UNRATENSA = { + **UNRATE, + "id": "UNRATENSA", + "title": "Unemployment Rate (Not Seasonally Adjusted)", + "seasonal_adjustment": "Not Seasonally Adjusted", + "seasonal_adjustment_short": "NSA", + "popularity": 60, +} + +GDPC1 = { + **UNRATE, + "id": "GDPC1", + "title": "Real Gross Domestic Product", + "frequency": "Quarterly", + "frequency_short": "Q", + "units": "Billions of Chained 2017 Dollars", + "popularity": 90, +} + +SERIES = {s["id"]: s for s in (UNRATE, CPIAUCSL, UNRATENSA, GDPC1)} + + +class MockFred: + """Routes FRED paths to captured payloads and records every request.""" + + def __init__(self) -> None: + self.requests: list[tuple[str, dict[str, str]]] = [] + + def transport(self) -> httpx.MockTransport: + return httpx.MockTransport(self._handle) + + def query(self, path_suffix: str) -> dict[str, str]: + """The most recent query recorded for a path ending in ``path_suffix``.""" + for path, params in reversed(self.requests): + if path.endswith(path_suffix): + return params + raise AssertionError(f"no request recorded for {path_suffix}; saw {[p for p, _ in self.requests]}") + + def _handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + params = dict(request.url.params) + self.requests.append((path, params)) + + # Exact, not endswith: /release/series and /category/series also end in + # "/series" and would otherwise be swallowed by this branch. + if path.endswith("/fred/series"): + return self._one_series(params.get("series_id", "")) + if path.endswith("/series/search"): + return self._series_list(params, [UNRATE, UNRATENSA, CPIAUCSL, GDPC1]) + if path.endswith("/release/series") or path.endswith("/category/series"): + return self._series_list(params, [UNRATE, CPIAUCSL]) + if path.endswith("/series/release"): + return _ok( + { + "releases": [ + { + "id": 50, + "realtime_start": "2026-08-05", + "realtime_end": "2026-08-05", + "name": "Employment Situation", + "press_release": True, + "link": "http://www.bls.gov/ces/", + } + ] + } + ) + if path.endswith("/series/categories"): + return _ok( + { + "categories": [ + { + "id": 32447, + "name": "Unemployment Rate", + "parent_id": 12, + "notes": "The ratio of unemployed to the civilian labor force.", + } + ] + } + ) + if path.endswith("/series/tags"): + return _ok( + { + "count": 2, + "tags": [ + {"name": "headline figure", "group_id": "gen", "notes": "", "popularity": 51}, + {"name": "monthly", "group_id": "freq", "notes": "", "popularity": 93}, + ], + } + ) + return _error(404, f"Not Found. No handler for {path}.") + + def _one_series(self, series_id: str) -> httpx.Response: + if series_id not in SERIES: + return _error(400, "Bad Request. The series does not exist.") + return _ok({"realtime_start": "2026-08-05", "realtime_end": "2026-08-05", "seriess": [SERIES[series_id]]}) + + def _series_list(self, params: dict[str, str], pool: list[dict]) -> httpx.Response: + """Applies FRED's tag filter, its ordering, and its limit.""" + rows = list(pool) + for tag in filter(None, params.get("tag_names", "").split(";")): + rows = [r for r in rows if tag in _tags_for(r)] + + order_by = params.get("order_by", "series_id") + reverse = params.get("sort_order", "asc") == "desc" + if order_by in {"popularity", "group_popularity"}: + rows.sort(key=lambda r: r.get(order_by, 0), reverse=reverse) + else: + rows.sort(key=lambda r: str(r.get(order_by, r["id"])), reverse=reverse) + + total = len(rows) + limit = int(params.get("limit", 1000)) + return _ok({"count": total, "offset": 0, "limit": limit, "seriess": rows[:limit]}) + + +def _tags_for(series: dict) -> set[str]: + """The freq and seas tags FRED would carry for a series, derived from its fields.""" + return { + series["frequency"].lower(), + "nsa" if series["seasonal_adjustment_short"] == "NSA" else "sa", + } + + +def _ok(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload) + + +def _error(status: int, message: str) -> httpx.Response: + return httpx.Response(status, json={"error_code": status, "error_message": message}) diff --git a/plugins/fred/tests/integration/conftest.py b/plugins/fred/tests/integration/conftest.py new file mode 100644 index 0000000..2f3cb04 --- /dev/null +++ b/plugins/fred/tests/integration/conftest.py @@ -0,0 +1,46 @@ +"""Integration tests drive the real tool functions against a mock FRED. + +No network and no API key: the client's injected transport is the seam. Tools are +reached through the registered MCP server rather than imported directly, so the test +exercises the same path a client does, decorators and JSON rendering included. +""" + +import json + +import pytest +from mcp.server import MCPServer + +from src import tools +from src.client import FredClient +from src.server import INSTRUCTIONS + +from ..fixtures.fred_api import MockFred + + +@pytest.fixture +def fred() -> MockFred: + return MockFred() + + +@pytest.fixture +def call(fred, monkeypatch): + """Return an async ``call(tool_name, **kwargs)`` that returns parsed JSON.""" + tools.reset_state() + monkeypatch.setattr(tools, "_client", FredClient(transport=fred.transport())) + + mcp = MCPServer("fred", instructions=INSTRUCTIONS) + tools.register_all(mcp) + + async def _call(name: str, **kwargs): + result = await mcp.call_tool(name, kwargs) + return json.loads(_text_of(result)) + + return _call + + +def _text_of(result: object) -> str: + """Pull the string payload out of a CallToolResult.""" + content = getattr(result, "content", result) + if isinstance(content, list): + content = content[0] + return getattr(content, "text", str(content)) diff --git a/plugins/fred/tests/integration/test_discovery.py b/plugins/fred/tests/integration/test_discovery.py new file mode 100644 index 0000000..508a4bb --- /dev/null +++ b/plugins/fred/tests/integration/test_discovery.py @@ -0,0 +1,135 @@ +"""search_series and get_series, driven through the registered MCP server.""" + +import pytest + +pytestmark = pytest.mark.integration + + +class TestSearchSeries: + async def test_free_text_search_orders_by_popularity(self, call, fred): + out = await call("search_series", query="unemployment") + assert out["scope"] == "search" + assert out["series"][0]["id"] == "UNRATE" + params = fred.query("/series/search") + assert params["search_text"] == "unemployment" + assert params["order_by"] == "popularity" + assert params["sort_order"] == "desc" + + async def test_the_key_is_sent_but_the_count_is_freds(self, call, fred): + out = await call("search_series", query="unemployment") + assert out["count"] == 4 + assert out["returned"] == len(out["series"]) + + async def test_release_id_switches_endpoint_without_changing_the_shape(self, call, fred): + out = await call("search_series", release_id=50) + assert out["scope"] == "release" + assert fred.query("/release/series")["release_id"] == "50" + assert set(out) >= {"scope", "count", "returned", "series"} + + async def test_category_id_switches_endpoint(self, call, fred): + out = await call("search_series", category_id=32447) + assert out["scope"] == "category" + assert fred.query("/category/series")["category_id"] == "32447" + + async def test_the_root_category_is_reachable(self, call, fred): + await call("search_series", category_id=0) + assert fred.query("/category/series")["category_id"] == "0" + + async def test_frequency_words_become_a_fred_tag(self, call, fred): + await call("search_series", query="unemployment", frequency="monthly") + assert fred.query("/series/search")["tag_names"] == "monthly" + + async def test_seasonal_shorthand_becomes_a_fred_tag(self, call, fred): + out = await call("search_series", query="unemployment", seasonal_adjustment="NSA") + assert fred.query("/series/search")["tag_names"] == "nsa" + assert [s["id"] for s in out["series"]] == ["UNRATENSA"] + + async def test_both_filters_go_to_the_api_together(self, call, fred): + # The reason this uses tag_names rather than filter_variable: FRED takes one + # filter_variable per request, so the second would have to be applied locally + # against a page FRED had already truncated, and count would stop being true. + out = await call("search_series", query="unemployment", frequency="m", seasonal_adjustment="NSA", limit=2) + params = fred.query("/series/search") + assert params["tag_names"] == "monthly;nsa" + assert int(params["limit"]) == 2 # no over-fetch: FRED did the filtering + assert [s["id"] for s in out["series"]] == ["UNRATENSA"] + assert out["count"] == 1 # the count describes exactly what was asked for + assert out["filters"] == {"frequency": "m", "seasonal_adjustment": "nsa", "order_by": "popularity"} + + async def test_saar_resolves_to_the_tag_that_exists(self, call, fred): + # FRED's seas tag group holds only sa and nsa; SAAR series carry sa. + await call("search_series", query="gdp", seasonal_adjustment="SAAR") + assert fred.query("/series/search")["tag_names"] == "sa" + + async def test_an_impossible_seasonal_adjustment_never_reaches_the_api(self, call, fred): + out = await call("search_series", query="x", seasonal_adjustment="partially adjusted") + assert "suggestions" in out + assert not fred.requests + + async def test_notes_are_not_in_a_result_list(self, call): + out = await call("search_series", query="unemployment") + assert all("notes" not in s for s in out["series"]) + + async def test_no_path_returns_guidance_not_a_traceback(self, call): + out = await call("search_series") + assert "did not pass validation" in out["error"] + assert any("category_id" in s for s in out["suggestions"]) + + async def test_an_impossible_frequency_never_reaches_the_api(self, call, fred): + out = await call("search_series", query="x", frequency="hourly") + assert "suggestions" in out + assert not fred.requests + + +class TestGetSeries: + async def test_metadata_by_default(self, call): + out = await call("get_series", series_ids=["UNRATE"]) + series = out["series"][0] + assert series["units"] == "Percent" + assert series["seasonal_adjustment"] == "SA" + assert "notes" not in series + assert "release" not in series + + async def test_lowercase_ids_are_corrected(self, call, fred): + out = await call("get_series", series_ids="unrate") + assert out["series"][0]["id"] == "UNRATE" + assert fred.query("/series")["series_id"] == "UNRATE" + + async def test_include_notes_adds_the_definition(self, call): + out = await call("get_series", series_ids=["UNRATE"], include=["metadata", "notes"]) + assert "unemployed as a percentage" in out["series"][0]["notes"] + + async def test_include_all_fans_out_to_every_endpoint(self, call, fred): + out = await call("get_series", series_ids=["UNRATE"], include=["all"]) + series = out["series"][0] + assert series["release"] == { + "id": 50, + "name": "Employment Situation", + "link": "http://www.bls.gov/ces/", + "press_release": True, + } + assert series["categories"] == [{"id": 32447, "name": "Unemployment Rate"}] + assert series["tags"] == ["headline figure", "monthly"] + + async def test_unrequested_endpoints_are_not_called(self, call, fred): + await call("get_series", series_ids=["UNRATE"]) + paths = {path for path, _ in fred.requests} + assert paths == {"/fred/series"} + + async def test_several_series_in_one_call(self, call): + out = await call("get_series", series_ids="UNRATE,CPIAUCSL") + assert [s["id"] for s in out["series"]] == ["UNRATE", "CPIAUCSL"] + + async def test_one_bad_id_does_not_take_down_the_good_ones(self, call): + # FRED's "The series does not exist" never says which, so failing the whole + # call would leave a model with three IDs and no idea which to fix. + out = await call("get_series", series_ids=["UNRATE", "NOPE", "CPIAUCSL"]) + by_id = {s["id"]: s for s in out["series"]} + assert by_id["UNRATE"]["units"] == "Percent" + assert by_id["CPIAUCSL"]["units"] == "Index 1982-1984=100" + assert "does not exist" in by_id["NOPE"]["error"] + assert "search_series" in by_id["NOPE"]["suggestion"] + + async def test_an_unknown_include_is_guidance_not_a_failure(self, call): + out = await call("get_series", series_ids=["UNRATE"], include=["observations"]) + assert any("include" in s for s in out["suggestions"]) diff --git a/plugins/fred/tests/unit/test_schemas.py b/plugins/fred/tests/unit/test_schemas.py new file mode 100644 index 0000000..476d4a7 --- /dev/null +++ b/plugins/fred/tests/unit/test_schemas.py @@ -0,0 +1,174 @@ +"""Correction, then validation. + +These are the misuse tests: each case is a request a model plausibly writes, and the +assertion is that it either becomes the right FRED call or fails with a message naming +the fix. +""" + +import pytest +from pydantic import ValidationError + +from src.schemas import GetSeriesArgs, SearchArgs, normalize_series_ids + +pytestmark = pytest.mark.unit + + +class TestSeriesIdCorrection: + @pytest.mark.parametrize( + "written,expected", + [ + ("unrate", ["UNRATE"]), + ("UNRATE", ["UNRATE"]), + ("UNRATE,CPIAUCSL", ["UNRATE", "CPIAUCSL"]), + ("UNRATE, CPIAUCSL", ["UNRATE", "CPIAUCSL"]), + ("UNRATE CPIAUCSL", ["UNRATE", "CPIAUCSL"]), + (["unrate", "cpiaucsl"], ["UNRATE", "CPIAUCSL"]), + (["UNRATE,CPIAUCSL"], ["UNRATE", "CPIAUCSL"]), + (" unrate ", ["UNRATE"]), + ], + ) + def test_the_shapes_a_model_writes(self, written, expected): + assert GetSeriesArgs(series_ids=written).series_ids == expected + + def test_duplicates_are_dropped_and_order_kept(self): + assert normalize_series_ids(["GDPC1", "unrate", "GDPC1"]) == ["GDPC1", "UNRATE"] + + def test_a_wrong_type_reaches_pydantic_as_a_type_error(self): + with pytest.raises(ValidationError): + GetSeriesArgs(series_ids=[123]) + + def test_empty_is_rejected(self): + with pytest.raises(ValidationError): + GetSeriesArgs(series_ids=[]) + + def test_the_fan_out_is_bounded(self): + with pytest.raises(ValidationError): + GetSeriesArgs(series_ids=[f"S{i}" for i in range(21)]) + + +class TestInclude: + def test_defaults_to_metadata(self): + assert GetSeriesArgs(series_ids="UNRATE").include == ["metadata"] + + def test_a_bare_string_becomes_a_list(self): + assert GetSeriesArgs(series_ids="UNRATE", include="notes").include == ["notes"] + + def test_comma_joined(self): + assert GetSeriesArgs(series_ids="UNRATE", include="notes,tags").include == ["notes", "tags"] + + def test_all_expands(self): + args = GetSeriesArgs(series_ids="UNRATE", include=["all"]) + assert set(args.include) == {"metadata", "notes", "release", "categories", "tags"} + + def test_case_is_forgiven(self): + assert GetSeriesArgs(series_ids="UNRATE", include=["Notes"]).include == ["notes"] + + def test_unknown_include_names_the_valid_ones(self): + with pytest.raises(ValidationError, match="observations"): + GetSeriesArgs(series_ids="UNRATE", include=["observations"]) + + +class TestFrequencyCorrection: + @pytest.mark.parametrize( + "written,expected", + [ + ("monthly", "m"), + ("Monthly", "m"), + ("month", "m"), + ("M", "m"), + ("quarterly", "q"), + ("annual", "a"), + ("yearly", "a"), + ("daily", "d"), + ("weekly", "w"), + ("bi-weekly", "bw"), + ("semiannual", "sa"), + ("", ""), + ], + ) + def test_words_become_codes(self, written, expected): + assert SearchArgs(query="x", frequency=written).frequency == expected + + def test_a_frequency_fred_does_not_have_is_rejected_locally(self): + with pytest.raises(ValidationError, match="unknown frequency"): + SearchArgs(query="x", frequency="hourly") + + +class TestSeasonalCorrection: + @pytest.mark.parametrize( + "written,expected", + [ + ("SA", "sa"), + ("sa", "sa"), + ("seasonally adjusted", "sa"), + ("adjusted", "sa"), + ("NSA", "nsa"), + ("not seasonally adjusted", "nsa"), + ("unadjusted", "nsa"), + # FRED's seas tag group holds only sa and nsa; SAAR series carry sa. + ("saar", "sa"), + ], + ) + def test_shorthand_becomes_the_tag_fred_filters_on(self, written, expected): + assert SearchArgs(query="x", seasonal_adjustment=written).seasonal_adjustment == expected + + def test_an_adjustment_fred_does_not_have_is_rejected_locally(self): + with pytest.raises(ValidationError, match="unknown seasonal_adjustment"): + SearchArgs(query="x", seasonal_adjustment="partially adjusted") + + +class TestTagNames: + def test_both_filters_join_into_one_tag_list(self): + args = SearchArgs(query="x", frequency="monthly", seasonal_adjustment="NSA") + assert args.tag_names == "monthly;nsa" + + def test_frequency_alone(self): + assert SearchArgs(query="x", frequency="q").tag_names == "quarterly" + + def test_the_semiannual_code_is_not_confused_with_the_sa_tag(self): + # frequency="sa" means semiannual; seasonal_adjustment="sa" means adjusted. + # They share a spelling and must not resolve to the same tag. + assert SearchArgs(query="x", frequency="sa").tag_names == "semiannual" + assert SearchArgs(query="x", seasonal_adjustment="sa").tag_names == "sa" + + def test_no_filters_means_no_tag_parameter(self): + assert SearchArgs(query="x").tag_names == "" + + +class TestSearchPaths: + def test_query_routes_to_search(self): + assert SearchArgs(query="cpi").path == "/series/search" + + def test_release_id_routes_to_release_series(self): + assert SearchArgs(release_id=50).path == "/release/series" + + def test_category_id_routes_to_category_series(self): + assert SearchArgs(category_id=32447).path == "/category/series" + + def test_the_root_category_is_not_mistaken_for_unset(self): + # category_id=0 is the FRED root, so a falsy check here would break browsing. + assert SearchArgs(category_id=0).path == "/category/series" + + def test_no_path_at_all_says_which_three_to_choose_from(self): + with pytest.raises(ValidationError, match="category_id"): + SearchArgs() + + def test_a_blank_query_is_not_a_path(self): + with pytest.raises(ValidationError): + SearchArgs(query=" ") + + def test_two_paths_at_once_is_rejected(self): + with pytest.raises(ValidationError, match="only one of"): + SearchArgs(query="cpi", release_id=50) + + +class TestSearchLimits: + def test_order_by_is_corrected_and_checked(self): + assert SearchArgs(query="x", order_by="Group Popularity").order_by == "group_popularity" + with pytest.raises(ValidationError, match="unknown order_by"): + SearchArgs(query="x", order_by="relevance") + + @pytest.mark.parametrize("limit", [0, 101]) + def test_limit_is_bounded(self, limit): + with pytest.raises(ValidationError): + SearchArgs(query="x", limit=limit) diff --git a/plugins/fred/tests/unit/test_server.py b/plugins/fred/tests/unit/test_server.py index b312f1b..5eb8a9b 100644 --- a/plugins/fred/tests/unit/test_server.py +++ b/plugins/fred/tests/unit/test_server.py @@ -20,7 +20,7 @@ async def test_server_builds_and_exposes_its_tools(): # Grows with the stack in #30. Asserting the exact set is what catches a tool that was # written but never registered, which is otherwise invisible until a user asks for it. -EXPECTED_TOOLS: set[str] = set() +EXPECTED_TOOLS: set[str] = {"search_series", "get_series"} def test_instructions_name_the_entry_point(): diff --git a/plugins/fred/tests/unit/test_shaping.py b/plugins/fred/tests/unit/test_shaping.py new file mode 100644 index 0000000..9c7bb66 --- /dev/null +++ b/plugins/fred/tests/unit/test_shaping.py @@ -0,0 +1,77 @@ +"""Trimming: what survives, what is dropped, and why.""" + +import pytest + +from src import shaping + +from ..fixtures.fred_api import CPIAUCSL, UNRATE + +pytestmark = pytest.mark.unit + + +class TestTrimSeries: + def test_keeps_what_decides_whether_a_number_means_what_you_think(self): + out = shaping.trim_series(UNRATE) + assert out["id"] == "UNRATE" + assert out["units"] == "Percent" + assert out["frequency"] == "Monthly" + assert out["seasonal_adjustment"] == "SA" + assert out["observation_end"] == "2026-06-01" + assert out["last_updated"].startswith("2026-07-02") + + def test_drops_the_fields_that_repeat_or_duplicate(self): + out = shaping.trim_series(UNRATE) + for dropped in ("realtime_start", "realtime_end", "frequency_short", "units_short", "group_popularity"): + assert dropped not in out + + def test_notes_are_opt_in(self): + assert "notes" not in shaping.trim_series(UNRATE) + assert "notes" in shaping.trim_series(UNRATE, notes=True) + + def test_notes_whitespace_is_collapsed(self): + # FRED notes carry \r\n\r\n paragraph breaks that cost tokens and read no better. + out = shaping.trim_series(UNRATE, notes=True) + assert "\r" not in out["notes"] and "\n" not in out["notes"] + assert "unemployed as a percentage of the labor force" in out["notes"] + + def test_a_sparse_series_object_does_not_invent_keys(self): + assert shaping.trim_series({"id": "X"}) == {"id": "X"} + + def test_trimming_is_most_of_the_payload(self): + # The claim the design rests on, asserted rather than asserted-in-prose. + before = len(str(UNRATE)) + after = len(str(shaping.trim_series(UNRATE))) + assert after < before * 0.5 + + +class TestSeriesList: + def test_reads_freds_misspelled_key(self): + out = shaping.series_list({"seriess": [UNRATE, CPIAUCSL]}) + assert [s["id"] for s in out] == ["UNRATE", "CPIAUCSL"] + + def test_an_empty_payload_is_an_empty_list(self): + assert shaping.series_list({}) == [] + + +class TestOtherTrims: + def test_release(self): + raw = {"id": 50, "name": "Employment Situation", "link": "http://x", "press_release": True, "realtime_start": "x"} # noqa: E501 + assert shaping.trim_release(raw) == { + "id": 50, + "name": "Employment Situation", + "link": "http://x", + "press_release": True, + } + + def test_category_keeps_id_and_name_only(self): + raw = {"id": 32447, "name": "Unemployment Rate", "parent_id": 12, "notes": "long prose"} + assert shaping.trim_category(raw) == {"id": 32447, "name": "Unemployment Rate"} + + def test_tags_become_names(self): + raw = [{"name": "monthly", "group_id": "freq", "popularity": 93}, {"name": "usa"}] + assert shaping.tag_names(raw) == ["monthly", "usa"] + + def test_first_or_empty(self): + assert shaping.first_or_empty({"seriess": [UNRATE]}, "seriess")["id"] == "UNRATE" + assert shaping.first_or_empty({"seriess": []}, "seriess") == {} + assert shaping.first_or_empty({}, "seriess") == {}