Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion plugins/fred/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
9 changes: 7 additions & 2 deletions plugins/fred/Makefile
Original file line number Diff line number Diff line change
@@ -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 .
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion plugins/fred/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://fredaccount.stlouisfed.org/apikeys>.

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"
Expand All @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions plugins/fred/commands/auth.md
Original file line number Diff line number Diff line change
@@ -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.
129 changes: 129 additions & 0 deletions plugins/fred/scripts/save-credentials.sh
Original file line number Diff line number Diff line change
@@ -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\": \"<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."
79 changes: 79 additions & 0 deletions plugins/fred/tests/unit/test_server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
Expand Down
Loading