diff --git a/README.md b/README.md index e6ae566..e6134b5 100644 --- a/README.md +++ b/README.md @@ -10,7 +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 | +| [`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, and `/fred:auth` to store your key). | 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 | @@ -38,13 +38,31 @@ Run these as two separate commands, not as one paste: the first opens a prompt t /plugin install harbor-hub ``` -The non-interactive equivalents are more reliable, and are the only way to move an existing install to a new version, since `claude plugin install` no-ops when the plugin is already present: +The non-interactive equivalents below are more reliable, and are the only way to move an existing install to a new version. + +MCP server plugins require [`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. See each server's README for credentials. Skill-only plugins have no such setup: `persona` needs nothing beyond the `python3` already on your system. + +### Updating + +Merging to `main` publishes: the marketplace *is* this repo. What each machine then needs is a refresh of its cached copy, which is one command: ```bash -claude plugin marketplace update walkerhughes && claude plugin update harbor-hub@walkerhughes +claude plugin marketplace update walkerhughes ``` -MCP server plugins require [`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. See each server's README for credentials. Skill-only plugins have no such setup: `persona` needs nothing beyond the `python3` already on your system. +Until that runs, a newly added plugin is invisible locally, however many times you try to install it. After it, the two cases differ: + +```bash +claude plugin install fred@walkerhughes +``` + +```bash +claude plugin update tastytrade@walkerhughes +``` + +Use `install` for a plugin you do not have yet, `update` for one you already have. `install` no-ops on an existing plugin rather than upgrading it, which reads as "nothing happened" rather than as an error. + +Then **restart Claude Code**. A plugin change needs a full restart, not just an `/mcp` reconnect. ### Shipping a plugin change diff --git a/plugins/fred/.claude-plugin/plugin.json b/plugins/fred/.claude-plugin/plugin.json index c0f2ba2..f96aa37 100644 --- a/plugins/fred/.claude-plugin/plugin.json +++ b/plugins/fred/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "fred", - "version": "0.6.0", + "version": "0.7.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/Makefile b/plugins/fred/Makefile index bd613ef..a4d346e 100644 --- a/plugins/fred/Makefile +++ b/plugins/fred/Makefile @@ -1,5 +1,5 @@ .PHONY: lint lint-fix format typecheck check test test-unit test-integration coverage \ - validate-tasks evals mock-api benchmark-build benchmark benchmark-view + validate-tasks evals mock-api benchmark-build benchmark benchmark-view selftest lint: uv run ruff check . @@ -13,7 +13,12 @@ format: typecheck: uv run mypy src/ -check: lint typecheck test-unit +check: lint typecheck selftest test-unit + +# The credential helper is shipped payload but lives outside src/, so pytest never +# imports it. It carries its own assertions; this is what runs them. +selftest: + bash scripts/save-credentials.sh --selftest test: uv run pytest diff --git a/plugins/fred/README.md b/plugins/fred/README.md index f82d027..bf11f16 100644 --- a/plugins/fred/README.md +++ b/plugins/fred/README.md @@ -26,7 +26,17 @@ Needs [`uv`](https://docs.astral.sh/uv/) on your PATH. The first launch builds t 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. +The easiest way is the slash command: + +``` +/fred:auth +``` + +It opens your operating system's own password prompt, checks the key against FRED, and writes it to `~/.fred-mcp/credentials.json` with owner-only permissions. Nothing is uploaded, and the key never passes through the conversation: the command runs [`scripts/save-credentials.sh`](scripts/save-credentials.sh), which reads the prompt itself rather than asking the agent to collect it. A key pasted into a chat is in the transcript and in the context window, and neither is something you can rotate away. + +Run it again any time to rotate: it always overwrites. A key FRED rejects is refused before the file is touched, so a mistyped rotation cannot cost you a working key. + +The two manual routes still work. The environment wins if both are set. ```bash export FRED_API_KEY="your32characterlowercasealnumkey" @@ -36,6 +46,8 @@ export FRED_API_KEY="your32characterlowercasealnumkey" mkdir -p ~/.fred-mcp && printf '{"api_key": "%s"}\n' "$FRED_API_KEY" > ~/.fred-mcp/credentials.json ``` +The file is usually the more reliable of the two, because a GUI launch of Claude Code does not inherit your shell profile, so an `export` in `.zshrc` never reaches the server. + 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. diff --git a/plugins/fred/commands/auth.md b/plugins/fred/commands/auth.md new file mode 100644 index 0000000..c834365 --- /dev/null +++ b/plugins/fred/commands/auth.md @@ -0,0 +1,39 @@ +--- +description: Save or replace your FRED API key, stored only at ~/.fred-mcp/credentials.json on this computer. +allowed-tools: Bash +--- + +Run the credential helper: + +```bash +bash ${CLAUDE_PLUGIN_ROOT}/scripts/save-credentials.sh +``` + +**Do not ask the user to type or paste their API key into this conversation, and do +not read it from any file.** The script prompts for it through the operating system's +own password dialog. That keeps the key out of the transcript and out of your context, +which is the entire point of running a script instead of just asking. If the script +fails, report what it said and let the user retry it; never offer to collect the key +yourself as a workaround. + +Before running it, tell the user in one or two lines what is about to happen: + +- A password prompt will open, with the field masked. +- The key is written only to `~/.fred-mcp/credentials.json` on this machine, with + owner-only permissions. Nothing is uploaded anywhere, and it is not sent to Anthropic + or stored in the conversation. +- The key is checked against FRED before it is saved, so a mistyped key cannot replace + a working one. +- Running this again always overwrites, which is how to rotate a key. + +Then run it and report the outcome: + +- On success, say where it was saved and whether it was verified. Mention that the FRED + tools pick it up on the next call, with no restart needed. +- On a cancelled or rejected key, say the existing file was left untouched and offer to + run it again. A free key comes from https://fredaccount.stlouisfed.org/apikeys. + +If the user would rather not use a file at all, the alternative is exporting +`FRED_API_KEY` in the environment Claude Code launches with, which takes precedence over +the file. Note that a GUI launch does not inherit a shell profile, so the file is usually +the more reliable of the two. diff --git a/plugins/fred/scripts/save-credentials.sh b/plugins/fred/scripts/save-credentials.sh new file mode 100755 index 0000000..cc6eae6 --- /dev/null +++ b/plugins/fred/scripts/save-credentials.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Store a FRED API key at ~/.fred-mcp/credentials.json. Backs the /fred:auth command. +# +# The key is read by this script, never by the agent. That is the whole reason this +# exists rather than the command simply asking for the key in chat: a key pasted into a +# conversation is in the transcript, in the context window, and in whatever the client +# persists, none of which the user can rotate away. Here it goes from an OS password +# prompt straight into a 0600 file, and nothing this script writes to stdout contains +# it. +# +# Re-running always overwrites, which is the rotation path. +# +# save-credentials.sh prompt, verify against FRED, write +# save-credentials.sh --selftest check the validator, touching nothing +set -euo pipefail + +CRED_DIR="${FRED_CRED_DIR:-$HOME/.fred-mcp}" +CRED_FILE="$CRED_DIR/credentials.json" +SIGNUP="https://fredaccount.stlouisfed.org/apikeys" + +# Same rule as src/client.py. Checked here so a bad paste is caught at the prompt +# rather than surfacing later as a FRED error about "variable api_key". +valid_key() { + [[ "$1" =~ ^[a-z0-9]{32}$ ]] +} + +if [ "${1:-}" = "--selftest" ]; then + fail=0 + check() { + if [ "$1" = yes ]; then valid_key "$2" || { echo "FAIL: should accept $3"; fail=1; } + else valid_key "$2" && { echo "FAIL: should reject $3"; fail=1; } || true + fi + } + check yes "0123456789abcdef0123456789abcdef" "a well-formed key" + check no "0123456789ABCDEF0123456789abcdef" "capitals" + check no "0123456789abcdef0123456789abcde" "31 characters" + check no "0123456789abcdef0123456789abcdef0" "33 characters" + check no " 123456789abcdef0123456789abcdef" "a leading space" + check no "0123456789abcdef-123456789abcdef" "a hyphen" + check no "" "an empty string" + [ "$fail" -eq 0 ] && echo "save-credentials selftest ok" + exit "$fail" +fi + +PROMPT="Paste your FRED API key. + +It is written only to $CRED_FILE on this computer. +Nothing is uploaded, and it is not shared with the agent. + +Free key: $SIGNUP" + +# Ask the OS, not the chat. Each branch returns the key on stdout and nothing else. +read_key() { + if [ -n "${FRED_API_KEY_STDIN:-}" ]; then + # Escape hatch for scripted setup; not used by the slash command. + cat + elif command -v osascript > /dev/null 2>&1; then + # `with hidden answer` masks the field, so the key is not left on screen + # either. A cancelled dialog exits non-zero and is handled below. + osascript \ + -e "set r to display dialog \"$PROMPT\" default answer \"\" with hidden answer with title \"FRED API key\"" \ + -e 'return text returned of r' 2> /dev/null + elif command -v zenity > /dev/null 2>&1; then + zenity --password --title="FRED API key" 2> /dev/null + elif command -v kdialog > /dev/null 2>&1; then + kdialog --password "$PROMPT" 2> /dev/null + elif [ -r /dev/tty ]; then + # No GUI. Works when the script is run from a terminal directly; -s keeps the + # key off the screen. + printf '%s\n' "$PROMPT" > /dev/tty + local typed + IFS= read -r -s typed < /dev/tty + printf '\n' > /dev/tty + printf '%s' "$typed" + else + return 3 + fi +} + +if ! key="$(read_key)"; then + case "$?" in + 3) echo "error: no way to prompt for the key here (no osascript, zenity, kdialog, or terminal)." >&2 + echo " Write it yourself: mkdir -p $CRED_DIR && echo '{\"api_key\": \"\"}' > $CRED_FILE" >&2 ;; + *) echo "cancelled: no key was entered, and $CRED_FILE was not changed." >&2 ;; + esac + exit 1 +fi + +key="$(printf '%s' "$key" | tr -d '[:space:]')" + +if [ -z "$key" ]; then + echo "cancelled: no key was entered, and $CRED_FILE was not changed." >&2 + exit 1 +fi + +if ! valid_key "$key"; then + # Never echo the key or any part of it, not even a prefix. + echo "error: that is not the shape of a FRED API key (32 lowercase alphanumeric characters)." >&2 + echo " Check for stray whitespace, quotes, or capitals, and re-copy it from $SIGNUP." >&2 + echo " $CRED_FILE was not changed." >&2 + exit 1 +fi + +# Verify before writing, so a bad paste cannot overwrite a working key during a +# rotation. stderr is discarded because curl's error text can echo the URL, and the +# key is a query parameter in it. +verified="unverified" +if command -v curl > /dev/null 2>&1; then + status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -G \ + --data-urlencode "series_id=UNRATE" \ + --data-urlencode "api_key=$key" \ + --data-urlencode "file_type=json" \ + "https://api.stlouisfed.org/fred/series" 2> /dev/null || true)" + case "$status" in + 200) verified="verified against FRED" ;; + 400) echo "error: FRED rejected that key. It may be mistyped or revoked." >&2 + echo " Check it at $SIGNUP. $CRED_FILE was not changed." >&2 + exit 1 ;; + *) verified="written but not verified (no answer from FRED; status ${status:-none})" ;; + esac +fi + +umask 077 +mkdir -p "$CRED_DIR" +printf '{"api_key": "%s"}\n' "$key" > "$CRED_FILE" +chmod 600 "$CRED_FILE" + +echo "Saved to $CRED_FILE ($verified)." +echo "Permissions are 0600, owner-only. Re-run /fred:auth any time to replace it." diff --git a/plugins/fred/tests/unit/test_server.py b/plugins/fred/tests/unit/test_server.py index ccbed0a..7600aa7 100644 --- a/plugins/fred/tests/unit/test_server.py +++ b/plugins/fred/tests/unit/test_server.py @@ -1,10 +1,13 @@ """The server builds and its manifest matches what ships.""" import json +import os +import subprocess from pathlib import Path import pytest +from src.client import _KEY_PATTERN from src.server import INSTRUCTIONS, build_server, version pytestmark = pytest.mark.unit @@ -34,6 +37,82 @@ def test_instructions_name_the_entry_point(): assert "search_series" in INSTRUCTIONS +class TestAuthCommand: + """The /fred:auth command and the script behind it. + + The invariant worth pinning is that the command never collects the key itself. A + key pasted into the conversation is in the transcript and in the context window, + neither of which the user can rotate away, so the command delegates to a script + that prompts through the OS instead. + """ + + COMMAND = ROOT / "commands" / "auth.md" + SCRIPT = ROOT / "scripts" / "save-credentials.sh" + + def test_the_command_exists_with_a_description(self): + text = self.COMMAND.read_text() + assert text.startswith("---") + front = text.split("---")[1] + assert "description:" in front + assert "~/.fred-mcp/credentials.json" in front + + def test_the_command_runs_the_script_that_exists(self): + assert self.SCRIPT.exists() + assert os.access(self.SCRIPT, os.X_OK), "the helper must be executable" + assert "scripts/save-credentials.sh" in self.COMMAND.read_text() + + def test_the_command_forbids_collecting_the_key_in_chat(self): + text = self.COMMAND.read_text().lower() + assert "do not ask the user to type or paste their api key" in text + + def test_the_script_never_prints_the_key(self, tmp_path): + """Run the rejection path and confirm the key is nowhere in the output. + + stdout is read by the agent, so a diagnostic that helpfully echoed what was + pasted would defeat the whole design. A malformed key is rejected locally, so + this needs no network. + """ + secret = "THIS-IS-THE-SECRET-VALUE-NOT-A-KEY" + result = subprocess.run( + ["bash", str(self.SCRIPT)], + input=secret, + capture_output=True, + text=True, + env={**os.environ, "FRED_CRED_DIR": str(tmp_path), "FRED_API_KEY_STDIN": "1"}, + ) + assert result.returncode == 1 + assert secret not in result.stdout + result.stderr + assert not (tmp_path / "credentials.json").exists() + + def test_no_echo_of_the_key_anywhere_in_the_script(self): + """The static half: the key reaches the file and nothing else.""" + for line in self.SCRIPT.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("#") or "$key" not in stripped: + continue + if stripped.startswith(("echo", "printf")): + assert stripped.endswith('> "$CRED_FILE"'), f"key must not reach the terminal: {stripped}" + + def test_the_script_validator_agrees_with_the_client(self): + """One rule for a well-formed key, checked in two places. + + The script rejects a bad paste at the prompt; client.py rejects it at call + time. If they drifted apart, /fred:auth would happily save a key the server + then refuses. + """ + assert "^[a-z0-9]{32}$" in self.SCRIPT.read_text() + assert _KEY_PATTERN.pattern == "^[a-z0-9]{32}$" + + def test_the_script_selftest_passes(self): + result = subprocess.run(["bash", str(self.SCRIPT), "--selftest"], capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + + def test_writing_is_the_last_step_after_verification(self): + """A mistyped key during a rotation must not overwrite a working one.""" + body = self.SCRIPT.read_text() + assert body.index("FRED rejected that key") < body.index('> "$CRED_FILE"') + + class TestPluginManifest: def test_mcp_json_points_at_the_launcher_that_exists(self): config = json.loads((ROOT / ".mcp.json").read_text())