diff --git a/.github/workflows/fred.yml b/.github/workflows/fred.yml new file mode 100644 index 0000000..0f860bd --- /dev/null +++ b/.github/workflows/fred.yml @@ -0,0 +1,44 @@ +name: fred + +# GitHub Actions only reads workflows from the repository root, so every job here +# sets working-directory to the plugin's subdirectory. The paths filters keep a +# change to one plugin from running another's suite. +on: + pull_request: + paths: + - "plugins/fred/**" + - ".github/workflows/fred.yml" + push: + branches: [main] + paths: + - "plugins/fred/**" + - ".github/workflows/fred.yml" + +defaults: + run: + working-directory: plugins/fred + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Lint & typecheck + run: make check + + # No API key and no network: the integration suite drives a mock FRED app + # in-process over an injected httpx transport. + - name: Coverage (unit + integration) + run: make coverage diff --git a/plugins/fred/.claude-plugin/plugin.json b/plugins/fred/.claude-plugin/plugin.json new file mode 100644 index 0000000..925e309 --- /dev/null +++ b/plugins/fred/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "fred", + "version": "0.1.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" + }, + "license": "MIT", + "homepage": "https://github.com/walkerhughes/claude/tree/main/plugins/fred", + "repository": "https://github.com/walkerhughes/claude", + "keywords": [ + "fred", + "economics", + "macro", + "time-series", + "mcp" + ] +} diff --git a/plugins/fred/.env.example b/plugins/fred/.env.example new file mode 100644 index 0000000..244d8db --- /dev/null +++ b/plugins/fred/.env.example @@ -0,0 +1,14 @@ +# The API key normally lives in ~/.fred-mcp/credentials.json as {"api_key": "..."}; +# this file is for CI and scripting, where an environment is easier to inject. +# +# Nothing sources this automatically. Export what you need, e.g. +# set -a && source .env && set +a +# +# Free key, no cost and no card: https://fredaccount.stlouisfed.org/apikeys +FRED_API_KEY="" + +# Optional; defaults to https://api.stlouisfed.org/fred. +# FRED_BASE_URL="https://api.stlouisfed.org/fred" + +# Optional logging level (stderr only; stdout is the MCP channel). +# FRED_LOG_LEVEL="INFO" diff --git a/plugins/fred/.gitignore b/plugins/fred/.gitignore new file mode 100644 index 0000000..29d2eea --- /dev/null +++ b/plugins/fred/.gitignore @@ -0,0 +1,16 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Environment variables +.env + +# Coverage +.coverage diff --git a/plugins/fred/.mcp.json b/plugins/fred/.mcp.json new file mode 100644 index 0000000..1882156 --- /dev/null +++ b/plugins/fred/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "fred": { + "type": "stdio", + "command": "bash", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/start-server.sh"], + "env": { + "FRED_API_KEY": "${FRED_API_KEY:-}", + "FRED_LOG_LEVEL": "${FRED_LOG_LEVEL:-}" + } + } + } +} diff --git a/plugins/fred/.python-version b/plugins/fred/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/plugins/fred/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/plugins/fred/Makefile b/plugins/fred/Makefile new file mode 100644 index 0000000..702216a --- /dev/null +++ b/plugins/fred/Makefile @@ -0,0 +1,27 @@ +.PHONY: lint lint-fix format typecheck check test test-unit test-integration coverage + +lint: + uv run ruff check . + +lint-fix: + uv run ruff check . --fix + +format: + uv run ruff format . + +typecheck: + uv run mypy src/ + +check: lint typecheck test-unit + +test: + uv run pytest + +test-unit: + uv run pytest -m unit + +test-integration: + uv run pytest -m integration + +coverage: + uv run pytest --cov --cov-report=term-missing diff --git a/plugins/fred/pyproject.toml b/plugins/fred/pyproject.toml new file mode 100644 index 0000000..eaea13e --- /dev/null +++ b/plugins/fred/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "fred-mcp" +version = "0.1.0" +description = "MCP server for the FRED economic data API" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + # 2.0 renamed FastMCP to MCPServer; the decorator API is otherwise the same. + # tastytrade is still on 1.x, so the two plugins differ here on purpose. + "mcp[cli]>=2.0", + "httpx>=0.27", + "pydantic>=2.0", +] + +# Not an installable package: the server runs as `python -m src.server` from the +# project root. Declaring it as one would need a build backend and a console +# script, neither of which anything here calls. +[tool.uv] +package = false + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-cov>=6.0", + "ruff>=0.9", + "mypy>=1.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +markers = [ + "unit: Unit tests (deselect with '-m \"not unit\"')", + "integration: Integration tests (deselect with '-m \"not integration\"')", +] +addopts = "-v" + +[tool.ruff] +line-length = 120 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + +[tool.coverage.run] +source = ["src"] +omit = ["tests/*"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true diff --git a/plugins/fred/scripts/start-server.sh b/plugins/fred/scripts/start-server.sh new file mode 100755 index 0000000..695f226 --- /dev/null +++ b/plugins/fred/scripts/start-server.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Launch the fred MCP server. +# +# Exists because "command": "uv" in .mcp.json assumes uv is on whatever PATH the +# MCP client spawns with. It often is not: a Homebrew uv lives in +# /opt/homebrew/bin, which is missing from the minimal PATH some launch contexts +# provide, and the failure surfaces only as an opaque JSON-RPC -32000. +# +# Diagnostics go to stderr. stdout is the JSON-RPC channel and must stay clean. +set -euo pipefail + +# Resolve the plugin root from this script's own location rather than +# CLAUDE_PLUGIN_ROOT, so it works however the server is launched. +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +find_uv() { + if command -v uv >/dev/null 2>&1; then + command -v uv + return + fi + # ponytail: fixed candidate list beats parsing shell profiles. Covers Homebrew + # on Apple Silicon and Intel, the standalone installer, and cargo install. + local candidate + for candidate in \ + /opt/homebrew/bin/uv \ + /usr/local/bin/uv \ + "$HOME/.local/bin/uv" \ + "$HOME/.cargo/bin/uv" + do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return + fi + done + return 1 +} + +if ! UV="$(find_uv)"; then + echo "fred-mcp: cannot find the 'uv' executable." >&2 + echo " Searched PATH plus /opt/homebrew/bin, /usr/local/bin, ~/.local/bin, ~/.cargo/bin." >&2 + echo " Install it (https://docs.astral.sh/uv/) or symlink it into /usr/local/bin." >&2 + exit 127 +fi + +# Drop any inherited VIRTUAL_ENV. uv already ignores a mismatched one, but it +# warns loudly on stderr about "does not match the project environment path", +# which reads like the cause of a failure. +unset VIRTUAL_ENV + +# Run as a module from the project root: pyproject sets tool.uv.package = false, +# so there is no installed console script to call. The API key resolves from the +# environment or an absolute path under $HOME, so this cd does not affect it. +cd "$ROOT" +exec "$UV" run --project "$ROOT" python -m src.server diff --git a/plugins/fred/src/__init__.py b/plugins/fred/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/fred/src/client.py b/plugins/fred/src/client.py new file mode 100644 index 0000000..b49e0e8 --- /dev/null +++ b/plugins/fred/src/client.py @@ -0,0 +1,161 @@ +"""FRED API client: key resolution, request signing, and retry. + +The API is read-only and authenticates with a single key, so there is no token +refresh and no write path to gate. What is left is worth doing carefully: resolving +the key from two places with a message that helps when it is in neither, and backing +off rather than failing when the 120-requests-per-minute limit is hit. +""" + +import asyncio +import json +import os +import re +import time +from pathlib import Path + +import httpx + +from .log import get_logger + +DEFAULT_BASE_URL = "https://api.stlouisfed.org/fred" +CREDENTIALS_PATH = Path.home() / ".fred-mcp" / "credentials.json" + +# FRED rejects anything else with an unhelpful message about variable api_key, so +# the shape is checked locally to name the real problem. +_KEY_PATTERN = re.compile(r"^[a-z0-9]{32}$") + +_MISSING_KEY = ( + "No FRED API key found. Set FRED_API_KEY in the environment, or write " + f'{CREDENTIALS_PATH} containing {{"api_key": ""}}. ' + "A key is free at https://fredaccount.stlouisfed.org/apikeys." +) + +# Retried statuses. 429 is the documented rate limit; the 5xx set covers the +# upstream blipping. Anything else (notably 400) is the caller's problem. +_RETRY_STATUSES = (429, 500, 502, 503, 504) +_BACKOFFS = (0.5, 1.0, 2.0) + + +class CredentialsError(RuntimeError): + """The API key is missing or malformed. Message is shown to the model verbatim.""" + + +def resolve_api_key() -> str: + """Return the API key from the environment, else the credentials file. + + Raises CredentialsError naming both locations when neither has one. The key is + never included in any message raised from here. + """ + env_key = (os.environ.get("FRED_API_KEY") or "").strip() + if env_key: + return _validated(env_key, "FRED_API_KEY") + + if CREDENTIALS_PATH.exists(): + try: + data = json.loads(CREDENTIALS_PATH.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise CredentialsError(f"{CREDENTIALS_PATH} could not be read as JSON: {exc}") from exc + file_key = str(data.get("api_key") or "").strip() + if file_key: + return _validated(file_key, str(CREDENTIALS_PATH)) + raise CredentialsError(f'{CREDENTIALS_PATH} has no "api_key" field. {_MISSING_KEY}') + + raise CredentialsError(_MISSING_KEY) + + +def _validated(key: str, source: str) -> str: + if not _KEY_PATTERN.match(key): + raise CredentialsError( + f"The FRED API key from {source} is not in the expected form " + "(32 lowercase alphanumeric characters). Check it for stray whitespace, " + "quotes, or capitals, and re-copy it from " + "https://fredaccount.stlouisfed.org/apikeys." + ) + return key + + +def _clean_params(params: dict) -> dict: + """Drop unset params and render the rest the way FRED expects. + + None and "" mean "not set" throughout the tool layer, so they are dropped here + rather than sent as empty values, which FRED rejects. + """ + out: dict[str, str] = {} + for key, value in params.items(): + if value is None or value == "": + continue + if isinstance(value, bool): + out[key] = "true" if value else "false" + elif isinstance(value, (list, tuple)): + if not value: + continue + out[key] = ",".join(str(v) for v in value) + else: + out[key] = str(value) + return out + + +class FredClient: + """Async client for https://api.stlouisfed.org/fred.""" + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + # Resolved lazily so that constructing a client (which happens at import time + # in the tool layer) never raises; a missing key should surface as a guided + # error from the tool that needed it, not as a server that will not start. + self._api_key = api_key + self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/") + self._transport = transport + self._http: httpx.AsyncClient | None = None + + def api_key(self) -> str: + if not self._api_key: + self._api_key = resolve_api_key() + return self._api_key + + async def _http_client(self) -> httpx.AsyncClient: + if self._http is None or self._http.is_closed: + self._http = httpx.AsyncClient( + base_url=self.base_url, + headers={"Accept": "application/json", "User-Agent": "fred-mcp/0.1"}, + timeout=30.0, + transport=self._transport, + ) + return self._http + + async def get(self, path: str, **params: object) -> dict: + """GET a FRED endpoint with the key and file_type injected. + + Retries 429 and 5xx with backoff, then raises for status so the caller's + @guarded_tool can turn the response body into guided suggestions. + """ + query = _clean_params(params) + query["api_key"] = self.api_key() + query["file_type"] = "json" + + http = await self._http_client() + log = get_logger() + + for attempt in range(len(_BACKOFFS) + 1): + start = time.monotonic() + resp = await http.get(path, params=query) + dur_ms = round((time.monotonic() - start) * 1000, 1) + log.debug("api_request path=%s status=%s ms=%s", path, resp.status_code, dur_ms) + + if resp.status_code in _RETRY_STATUSES and attempt < len(_BACKOFFS): + delay = _BACKOFFS[attempt] + log.warning("api_retry path=%s status=%s attempt=%s delay=%s", path, resp.status_code, attempt, delay) + await asyncio.sleep(delay) + continue + break + + resp.raise_for_status() + return resp.json() + + async def close(self) -> None: + if self._http and not self._http.is_closed: + await self._http.aclose() diff --git a/plugins/fred/src/errors.py b/plugins/fred/src/errors.py new file mode 100644 index 0000000..de1bec0 --- /dev/null +++ b/plugins/fred/src/errors.py @@ -0,0 +1,154 @@ +"""Guided error handling, modeled on Honeycomb's ``handleToolError``. + +Tools never hand a traceback to the model. Every failure becomes a small JSON object: +a readable ``error`` and a ``suggestions`` list of concrete next steps. That is what +keeps a model from failing, adjusting, failing again, and giving up on the question. + +FRED puts the real reason in the body rather than the status. A bad series ID and a +bad ``units`` code are both HTTP 400, and only ``error_message`` tells them apart, so +the body is what drives the suggestions here. +""" + +import functools +import json +from typing import Awaitable, Callable + +import httpx +from pydantic import ValidationError + +from .client import CredentialsError +from .log import get_logger + + +def error_response(message: str, suggestions: list[str] | None = None, **extra: object) -> str: + """Render a guided error as a JSON string (the uniform tool failure shape).""" + payload: dict[str, object] = {"error": message} + if suggestions: + payload["suggestions"] = suggestions + payload.update(extra) + return json.dumps(payload, indent=2, default=str) + + +# Matched against a lowercased FRED error_message. First hit wins, so the more +# specific phrases come first. +_BODY_GUIDANCE: tuple[tuple[str, list[str]], ...] = ( + ( + "series does not exist", + [ + "The series ID is wrong or the series has been discontinued.", + "Find the right one with search_series, which returns IDs ordered by popularity.", + "IDs are case-sensitive and uppercase, e.g. UNRATE, CPIAUCSL, GDPC1, DFF.", + ], + ), + ( + "no vintage dates exist", + [ + "This asks for revision history over a real-time window that has none.", + "get_revisions sets the window itself; call it rather than passing " + "realtime_start or realtime_end by hand.", + ], + ), + ( + "frequency", + [ + "A series can only be aggregated to a coarser frequency, never a finer one.", + "Check the series' native frequency with get_series, then request that or coarser: " + "d, w, bw, m, q, sa, a.", + ], + ), + ( + "api_key", + [ + "The key was rejected as malformed or unknown.", + "Set FRED_API_KEY, or write ~/.fred-mcp/credentials.json. " + "Ask the user to do this; never ask them to paste a key into the chat.", + ], + ), + ( + "variable", + [ + "One parameter value was rejected. The message above names it.", + "Dates must be YYYY-MM-DD. Units must be one of lin, chg, ch1, pch, pc1, pca, cch, cca, log.", + ], + ), +) + +_STATUS_GUIDANCE: dict[int, tuple[str, list[str]]] = { + 404: ("The endpoint was not found.", ["This is a bug in the server, not in the arguments."]), + 429: ( + "Rate limited by the FRED API (120 requests per minute).", + [ + "Wait a few seconds and retry.", + "Ask for several series in one get_observations call rather than one call each.", + ], + ), +} + + +def _from_http_error(exc: httpx.HTTPStatusError) -> str: + status = exc.response.status_code + try: + body = exc.response.json() + except ValueError: + body = {} + + detail = str(body.get("error_message", "")).strip() if isinstance(body, dict) else "" + if detail: + # FRED double-spaces after the leading "Bad Request." sentence. + message = " ".join(detail.split()) + lowered = message.lower() + for needle, suggestions in _BODY_GUIDANCE: + if needle in lowered: + return error_response(f"FRED rejected the request: {message}", suggestions, status_code=status) + return error_response(f"FRED rejected the request: {message}", status_code=status) + + fallback_message, suggestions = _STATUS_GUIDANCE.get( + status, (f"The FRED API returned HTTP {status}.", ["Retry, or simplify the request."]) + ) + return error_response(fallback_message, suggestions, status_code=status) + + +def _from_validation_error(exc: ValidationError) -> str: + suggestions: list[str] = [] + for err in exc.errors(): + loc = ".".join(str(p) for p in err.get("loc", ())) + msg = err.get("msg", "invalid value") + suggestions.append(f"{loc}: {msg}" if loc else msg) + return error_response( + "The tool arguments did not pass validation.", + suggestions or ["Re-read the tool docstring for the required argument shape."], + ) + + +def guarded_tool(func: Callable[..., Awaitable[str]]) -> Callable[..., Awaitable[str]]: + """Wrap an async tool so any failure returns a guided error string, never a traceback.""" + + @functools.wraps(func) + async def wrapper(*args: object, **kwargs: object) -> str: + try: + return await func(*args, **kwargs) + except httpx.HTTPStatusError as exc: + get_logger().warning("http_error tool=%s status=%s", func.__name__, exc.response.status_code) + return _from_http_error(exc) + except ValidationError as exc: + return _from_validation_error(exc) + except httpx.HTTPError as exc: # network/timeout + return error_response( + f"Network error contacting FRED: {type(exc).__name__}.", + ["Check connectivity to api.stlouisfed.org.", "Retry in a moment."], + ) + except CredentialsError as exc: + # The message already names both locations and the signup URL, so it is + # passed through verbatim. + get_logger().warning("credentials_error tool=%s", func.__name__) + return error_response(str(exc), ["See the fred plugin README for setup."]) + except (KeyError, ValueError, TypeError) as exc: + get_logger().warning("tool_value_error tool=%s err=%s", func.__name__, exc) + return error_response(f"Could not process the request: {exc}", ["Re-check the arguments and retry."]) + except Exception as exc: # noqa: BLE001 + # Last resort. Without this the docstring's promise is false: anything + # outside the cases above reaches the client as a raw traceback. + get_logger().exception("unexpected_error tool=%s", func.__name__) + return error_response(f"{type(exc).__name__}: {exc}") + + return wrapper diff --git a/plugins/fred/src/log.py b/plugins/fred/src/log.py new file mode 100644 index 0000000..cf6af40 --- /dev/null +++ b/plugins/fred/src/log.py @@ -0,0 +1,26 @@ +"""Logging for a stdio MCP server. + +stdout is the JSON-RPC channel, so every record goes to stderr. Anything written to +stdout corrupts the protocol and the client sees a parse error rather than a log line. +""" + +import logging +import os +import sys + +LOGGER_NAME = "fred-mcp" + + +def configure_logging(level: str | None = None) -> None: + """Attach a single stderr handler at the configured level. Idempotent.""" + logger = logging.getLogger(LOGGER_NAME) + logger.setLevel((level or os.environ.get("FRED_LOG_LEVEL") or "INFO").upper()) + logger.propagate = False + if not logger.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + logger.addHandler(handler) + + +def get_logger() -> logging.Logger: + return logging.getLogger(LOGGER_NAME) diff --git a/plugins/fred/src/server.py b/plugins/fred/src/server.py new file mode 100644 index 0000000..8c7cad2 --- /dev/null +++ b/plugins/fred/src/server.py @@ -0,0 +1,63 @@ +"""FRED MCP server. + +Task-oriented tools built for a model rather than one wrapper per REST endpoint. See +docs/design.md for the reasoning. + +Tool surface: + Discovery: search_series, get_series + Data: get_observations + Revisions: get_revisions + Schedule: get_release_calendar +""" + +import json +from pathlib import Path + +from mcp.server import MCPServer + +from .log import configure_logging +from .tools import register_all + +_MANIFEST = Path(__file__).resolve().parent.parent / ".claude-plugin" / "plugin.json" + +INSTRUCTIONS = ( + "Economic data from FRED (Federal Reserve Bank of St. Louis). Series are named by " + "opaque IDs, so start with search_series unless you already know the ID; it orders " + "by popularity, so the canonical series comes first. get_series explains what a " + "series measures, in what units, and how current it is. get_observations is the " + "workhorse: pass several series IDs at once to get them aligned on one date index, " + "and use units='yoy' for year-over-year percent change rather than doing the " + "arithmetic yourself. get_revisions answers what a number was first reported as. " + "get_release_calendar shows what data just came out and what is scheduled next." +) + + +def version() -> str: + """Read the shipped version from the plugin manifest. + + Hardcoding it here would drift from plugin.json, which the repo's plugin-version + workflow requires be bumped on every change. One source, so it cannot. + """ + try: + return str(json.loads(_MANIFEST.read_text())["version"]) + except (OSError, ValueError, KeyError): + return "" + + +def build_server() -> MCPServer: + """Construct and configure the MCP server with all tools.""" + configure_logging() + mcp = MCPServer("fred", instructions=INSTRUCTIONS, version=version()) + register_all(mcp) + return mcp + + +mcp = build_server() + + +def main() -> None: + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/plugins/fred/src/tools.py b/plugins/fred/src/tools.py new file mode 100644 index 0000000..b2be9b4 --- /dev/null +++ b/plugins/fred/src/tools.py @@ -0,0 +1,35 @@ +"""MCP tools. Each is registered on the FastMCP server by ``register_all``. + +Tools are added over the stack in #30; this module is the seam they attach to. +""" + +import json + +from mcp.server import MCPServer + +from .client import FredClient + +_client: FredClient | None = None + + +def get_client() -> FredClient: + """Lazy-init the API client so the key is read at tool time, not import time.""" + global _client + if _client is None: + _client = FredClient() + return _client + + +def reset_state() -> None: + """Test hook: drop the client singleton.""" + global _client + _client = None + + +def fmt(data: object) -> str: + """Render a tool result as compact, stable JSON.""" + return json.dumps(data, indent=2, default=str) + + +def register_all(mcp: MCPServer) -> None: + """Register every tool on the given MCP server.""" diff --git a/plugins/fred/tests/__init__.py b/plugins/fred/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/fred/tests/conftest.py b/plugins/fred/tests/conftest.py new file mode 100644 index 0000000..e7e283c --- /dev/null +++ b/plugins/fred/tests/conftest.py @@ -0,0 +1,22 @@ +"""Shared fixtures. + +Every test runs with FRED_API_KEY forced to a valid-shaped dummy, so nothing here can +reach the real API or depend on the developer's own key being set. +""" + +import pytest + +DUMMY_KEY = "abcdef0123456789abcdef0123456789" + + +@pytest.fixture(autouse=True) +def _isolated_env(monkeypatch, tmp_path): + monkeypatch.setenv("FRED_API_KEY", DUMMY_KEY) + # Point the credentials-file fallback at an empty tmp dir so a real + # ~/.fred-mcp/credentials.json on the machine cannot influence a test. + monkeypatch.setattr("src.client.CREDENTIALS_PATH", tmp_path / "credentials.json") + from src import tools + + tools.reset_state() + yield + tools.reset_state() diff --git a/plugins/fred/tests/integration/__init__.py b/plugins/fred/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/fred/tests/unit/__init__.py b/plugins/fred/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/fred/tests/unit/test_client.py b/plugins/fred/tests/unit/test_client.py new file mode 100644 index 0000000..36d7e72 --- /dev/null +++ b/plugins/fred/tests/unit/test_client.py @@ -0,0 +1,145 @@ +"""Key resolution, parameter cleaning, and retry.""" + +import json + +import httpx +import pytest + +from src.client import CredentialsError, FredClient, _clean_params, resolve_api_key + +from ..conftest import DUMMY_KEY + +pytestmark = pytest.mark.unit + + +class TestResolveApiKey: + def test_environment_wins(self): + assert resolve_api_key() == DUMMY_KEY + + def test_falls_back_to_the_credentials_file(self, monkeypatch, tmp_path): + monkeypatch.delenv("FRED_API_KEY") + path = tmp_path / "credentials.json" + path.write_text(json.dumps({"api_key": "0123456789abcdef0123456789abcdef"})) + monkeypatch.setattr("src.client.CREDENTIALS_PATH", path) + assert resolve_api_key() == "0123456789abcdef0123456789abcdef" + + def test_blank_environment_falls_through_to_the_file(self, monkeypatch, tmp_path): + # An exported-but-empty FRED_API_KEY is what .mcp.json's "${FRED_API_KEY:-}" + # produces when the user has not set one, so it must not shadow the file. + monkeypatch.setenv("FRED_API_KEY", " ") + path = tmp_path / "credentials.json" + path.write_text(json.dumps({"api_key": "0123456789abcdef0123456789abcdef"})) + monkeypatch.setattr("src.client.CREDENTIALS_PATH", path) + assert resolve_api_key() == "0123456789abcdef0123456789abcdef" + + def test_neither_set_names_both_locations(self, monkeypatch): + monkeypatch.delenv("FRED_API_KEY") + with pytest.raises(CredentialsError) as exc: + resolve_api_key() + message = str(exc.value) + assert "FRED_API_KEY" in message + assert "credentials.json" in message + assert "fredaccount.stlouisfed.org" in message + + def test_malformed_key_is_caught_locally(self, monkeypatch): + monkeypatch.setenv("FRED_API_KEY", "NOT-A-REAL-KEY") + with pytest.raises(CredentialsError, match="32 lowercase alphanumeric"): + resolve_api_key() + + def test_the_key_is_never_echoed(self, monkeypatch): + monkeypatch.setenv("FRED_API_KEY", "SECRETSECRETSECRETSECRETSECRET12") + with pytest.raises(CredentialsError) as exc: + resolve_api_key() + assert "SECRET" not in str(exc.value) + + def test_unparseable_credentials_file(self, monkeypatch, tmp_path): + monkeypatch.delenv("FRED_API_KEY") + path = tmp_path / "credentials.json" + path.write_text("{not json") + monkeypatch.setattr("src.client.CREDENTIALS_PATH", path) + with pytest.raises(CredentialsError, match="could not be read as JSON"): + resolve_api_key() + + def test_credentials_file_without_the_field(self, monkeypatch, tmp_path): + monkeypatch.delenv("FRED_API_KEY") + path = tmp_path / "credentials.json" + path.write_text(json.dumps({"key": "wrong field name"})) + monkeypatch.setattr("src.client.CREDENTIALS_PATH", path) + with pytest.raises(CredentialsError, match='no "api_key" field'): + resolve_api_key() + + +class TestCleanParams: + def test_drops_unset_values(self): + assert _clean_params({"a": 1, "b": None, "c": "", "d": []}) == {"a": "1"} + + def test_renders_booleans_and_lists_the_way_fred_wants(self): + cleaned = _clean_params({"flag": True, "off": False, "ids": ["A", "B"]}) + assert cleaned == {"flag": "true", "off": "false", "ids": "A,B"} + + def test_zero_survives(self): + # 0 is a real value for offset and for category_id (the FRED root category). + assert _clean_params({"offset": 0, "category_id": 0}) == {"offset": "0", "category_id": "0"} + + +class TestGet: + async def test_injects_the_key_and_file_type(self): + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(request.url.params) + return httpx.Response(200, json={"ok": True}) + + client = FredClient(transport=httpx.MockTransport(handler)) + assert await client.get("/series", series_id="UNRATE") == {"ok": True} + assert seen == {"series_id": "UNRATE", "api_key": DUMMY_KEY, "file_type": "json"} + await client.close() + + async def test_retries_429_then_succeeds(self, monkeypatch): + monkeypatch.setattr("src.client._BACKOFFS", (0, 0, 0)) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response(429, json={"error_code": 429}) + return httpx.Response(200, json={"ok": True}) + + client = FredClient(transport=httpx.MockTransport(handler)) + assert await client.get("/series") == {"ok": True} + assert calls["n"] == 2 + await client.close() + + async def test_gives_up_after_the_backoffs_run_out(self, monkeypatch): + monkeypatch.setattr("src.client._BACKOFFS", (0, 0, 0)) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(429, json={"error_code": 429}) + + client = FredClient(transport=httpx.MockTransport(handler)) + with pytest.raises(httpx.HTTPStatusError): + await client.get("/series") + assert calls["n"] == 4 # the first attempt plus one per backoff + await client.close() + + async def test_400_is_not_retried(self, monkeypatch): + monkeypatch.setattr("src.client._BACKOFFS", (0, 0, 0)) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(400, json={"error_code": 400, "error_message": "Bad Request."}) + + client = FredClient(transport=httpx.MockTransport(handler)) + with pytest.raises(httpx.HTTPStatusError): + await client.get("/series") + assert calls["n"] == 1 + await client.close() + + async def test_a_missing_key_surfaces_at_call_time_not_construction(self, monkeypatch): + monkeypatch.delenv("FRED_API_KEY") + client = FredClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json={}))) + with pytest.raises(CredentialsError): + await client.get("/series") diff --git a/plugins/fred/tests/unit/test_errors.py b/plugins/fred/tests/unit/test_errors.py new file mode 100644 index 0000000..a1576a8 --- /dev/null +++ b/plugins/fred/tests/unit/test_errors.py @@ -0,0 +1,117 @@ +"""Every failure a tool can hit becomes {error, suggestions}, never a traceback.""" + +import json + +import httpx +import pytest +from pydantic import BaseModel, ValidationError + +from src.client import CredentialsError +from src.errors import guarded_tool + +pytestmark = pytest.mark.unit + + +def http_error(status: int, body: object) -> httpx.HTTPStatusError: + request = httpx.Request("GET", "https://api.stlouisfed.org/fred/series") + response = httpx.Response(status, json=body, request=request) + return httpx.HTTPStatusError("boom", request=request, response=response) + + +async def run_raising(exc: Exception) -> dict: + @guarded_tool + async def tool() -> str: + raise exc + + return json.loads(await tool()) + + +class TestFredBodyGuidance: + async def test_missing_series_points_at_search_series(self): + body = {"error_code": 400, "error_message": "Bad Request. The series does not exist."} + result = await run_raising(http_error(400, body)) + assert "The series does not exist" in result["error"] + assert any("search_series" in s for s in result["suggestions"]) + assert result["status_code"] == 400 + + async def test_the_doubled_space_in_fred_messages_is_normalized(self): + body = {"error_code": 400, "error_message": "Bad Request. The series does not exist."} + result = await run_raising(http_error(400, body)) + assert "Request. The" not in result["error"] + + async def test_missing_vintages_points_at_get_revisions(self): + body = { + "error_code": 400, + "error_message": "Bad Request. No vintage dates exist for the specified real-time period.", + } + result = await run_raising(http_error(400, body)) + assert any("get_revisions" in s for s in result["suggestions"]) + + async def test_frequency_error_explains_the_coarser_only_rule(self): + body = { + "error_code": 400, + "error_message": "Bad Request. The frequency is not lower or equal to the series frequency.", + } + result = await run_raising(http_error(400, body)) + assert any("coarser" in s for s in result["suggestions"]) + + async def test_rejected_key_never_asks_the_user_to_paste_it(self): + body = {"error_code": 400, "error_message": "Bad Request. The value for variable api_key is not valid."} + result = await run_raising(http_error(400, body)) + assert any("never ask them to paste" in s.lower() for s in result["suggestions"]) + + async def test_unrecognized_body_still_surfaces_the_message(self): + body = {"error_code": 400, "error_message": "Bad Request. Something entirely new."} + result = await run_raising(http_error(400, body)) + assert "Something entirely new" in result["error"] + + +class TestStatusFallback: + async def test_429_without_a_body(self): + result = await run_raising(http_error(429, {})) + assert "Rate limited" in result["error"] + assert any("one get_observations call" in s for s in result["suggestions"]) + + async def test_unmapped_status(self): + result = await run_raising(http_error(503, {})) + assert "HTTP 503" in result["error"] + + async def test_non_json_body(self): + request = httpx.Request("GET", "https://api.stlouisfed.org/fred/series") + response = httpx.Response(500, text="gateway", request=request) + result = await run_raising(httpx.HTTPStatusError("boom", request=request, response=response)) + assert "HTTP 500" in result["error"] + + +class TestOtherFailures: + async def test_validation_error_lists_each_field(self): + class Args(BaseModel): + limit: int + + try: + Args(limit="not a number") + except ValidationError as exc: + captured = exc + result = await run_raising(captured) + assert result["error"].startswith("The tool arguments did not pass validation") + assert any(s.startswith("limit:") for s in result["suggestions"]) + + async def test_network_error(self): + result = await run_raising(httpx.ConnectTimeout("timed out")) + assert "Network error" in result["error"] + + async def test_credentials_error_passes_the_message_through(self): + result = await run_raising(CredentialsError("No FRED API key found. Set FRED_API_KEY.")) + assert result["error"] == "No FRED API key found. Set FRED_API_KEY." + + async def test_an_unexpected_exception_does_not_escape(self): + # The whole promise of @guarded_tool: nothing reaches the model as a traceback. + result = await run_raising(RuntimeError("something nobody anticipated")) + assert result["error"] == "RuntimeError: something nobody anticipated" + + async def test_a_successful_tool_is_untouched(self): + @guarded_tool + async def tool() -> str: + return '{"ok": true}' + + assert await tool() == '{"ok": true}' diff --git a/plugins/fred/tests/unit/test_server.py b/plugins/fred/tests/unit/test_server.py new file mode 100644 index 0000000..b312f1b --- /dev/null +++ b/plugins/fred/tests/unit/test_server.py @@ -0,0 +1,59 @@ +"""The server builds and its manifest matches what ships.""" + +import json +from pathlib import Path + +import pytest + +from src.server import INSTRUCTIONS, build_server, version + +pytestmark = pytest.mark.unit + +ROOT = Path(__file__).resolve().parents[2] + + +async def test_server_builds_and_exposes_its_tools(): + mcp = build_server() + names = {tool.name for tool in await mcp.list_tools()} + assert names == EXPECTED_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() + + +def test_instructions_name_the_entry_point(): + # A model that does not know to start with search_series will guess series IDs. + assert "search_series" in INSTRUCTIONS + + +class TestPluginManifest: + def test_mcp_json_points_at_the_launcher_that_exists(self): + config = json.loads((ROOT / ".mcp.json").read_text()) + server = config["mcpServers"]["fred"] + assert server["type"] == "stdio" + script = server["args"][0].replace("${CLAUDE_PLUGIN_ROOT}/", "") + assert (ROOT / script).exists() + + def test_the_launcher_is_executable(self): + # Committed without the bit set, .mcp.json's "bash