Labor Market Intelligence
by harperbrian
README.md
# Labor Market Intelligence — Remote MCP Server
A read-only remote MCP server exposing U.S. Bureau of Labor Statistics (BLS)
and FRED (Federal Reserve Bank of St. Louis) data to Claude as a custom
connector — for career and labor-market research: employment trends,
unemployment, job openings, hires, quits, wages, occupational outlook, and
industry comparisons.
Runs on Cloudflare Workers. Cost: **$0/month** on Cloudflare's free tier.
Built incrementally against a fully verified implementation plan — every BLS
and FRED endpoint, series ID, and data-shape quirk referenced in this codebase
was confirmed against the live APIs (not assumed from documentation alone)
before being implemented.
## Status
All 10 implementation checkpoints complete and live-verified against the
deployed Cloudflare Worker. 89 unit tests, clean typecheck, all 16 tools
confirmed working against real BLS/FRED data.
## Connecting to Claude
1. In Claude, go to **Settings → Connectors → Add custom connector**.
2. **Remote MCP server URL**: `https://<your-worker>.<your-subdomain>.workers.dev/mcp/<MCP_PATH_TOKEN>`
— treat this URL as a credential; the token is the only thing authorizing access.
3. **OAuth Client ID / Secret**: leave both blank. This server is authless —
the secret path *is* the credential, and Claude supports authless remote
MCP servers natively.
4. **Transport**: Streamable HTTP (SSE is the legacy fallback).
## Tools (16)
### Low-level source tools — thin, faithful passthrough to BLS/FRED
| Tool | What it does |
|---|---|
| `fred_search_series` | Full-text search over FRED series |
| `fred_get_series` | Fetch observations for a known FRED series ID |
| `fred_get_latest` | Fetch only the most recent FRED observation |
| `bls_get_series` | Fetch up to 50 BLS series over up to a 20-year span; `aspects=true` unlocks Employment Projections' projected employment/openings/wage |
| `bls_list_surveys` | List all 70 BLS survey program abbreviations |
| `bls_popular_series` | List BLS's "most requested" series (not universal — returns nothing for EP/JOLTS/OR) |
| `search_indicators` | Search ~13 curated headline indicators (unemployment, JOLTS metrics, payrolls, etc.) for their BLS/FRED IDs |
| `search_occupations` | Search ~1,113 SOC occupation titles for their BLS Employment Projections series ID |
### Research tools — composed, higher-level analysis
| Tool | What it does |
|---|---|
| `analyze_labor_market_trend` | Change and CAGR for one BLS/FRED series over a date range |
| `compare_labor_market_series` | Compare 2-10 BLS/FRED series (can mix sources) side by side |
| `analyze_job_market_conditions` | Snapshot: unemployment, payrolls, openings, hires, quits, layoffs — each with 1mo/12mo change |
| `analyze_industry_employment` | Long-run BLS Employment Projections outlook for a whole industry |
| `get_occupation_outlook` | Full BLS Employment Projections outlook for one occupation (employment, openings, median wage) |
| `compare_occupations` | Compare 2-20 occupations' outlook in a single batched BLS call |
| `analyze_wage_trends` | Trend/CAGR for an aggregate wage measure (default: average hourly earnings) |
| `ping` | Connectivity check; uses no BLS/FRED quota |
Every tool is annotated `readOnlyHint: true` and enforced by an automated test
(`test/unit/server.test.ts`) — no tool can mutate state, and none accepts a
caller-supplied URL to fetch.
## What each source actually provides
Verified against the live APIs, not assumed from documentation:
- **FRED** provides broad macro context (GDP, rates, recession indicators),
unit transforms, and a real full-text series search. It also republishes
many BLS series (UNRATE, PAYEMS, JOLTS metrics) with a clean uniform cadence.
- **BLS** is authoritative for anything occupational: Employment Projections
(`EP`, outlook/openings/wage) and OEWS (`OE`, current wages) exist only on
the BLS side — FRED does not carry the National Employment Matrix.
- **OEWS has no history via the API** — every series returns exactly one
reference year, confirmed by requesting a 10-year range and getting nine
"No Data Available" messages plus one datapoint. Occupational wage *trends*
are not retrievable from this API; use `get_occupation_outlook` for a
current-year median wage snapshot instead.
- **Employment Projections is not a time series** — one base year + one
projection year (currently ~10 years out), updated at most twice a year.
- **Annual openings figures include replacement demand** (workers exiting or
transferring out), not just net employment growth — a common misreading.
- **No official BLS series-search API exists.** `search_indicators` and
`search_occupations` are backed by a catalog derived from BLS's own flat
files and cross-validated during the build (see `scripts/build-catalog.ts`),
not hand-typed or guessed.
## Attribution
Every BLS-derived response carries the retrieval timestamp and the exact
disclaimer required by BLS's Terms of Service: *"BLS.gov cannot vouch for the
data or analyses derived from these data after the data have been retrieved
from BLS.gov."* FRED-derived responses carry their own source attribution.
Anything this server computes (percent change, CAGR, month-over-month deltas)
is returned under a separate `computedByServer` field, explicitly labeled as
server-calculated — never presented as an official BLS or FRED statistic.
Tool instructions direct Claude to preserve both when answering.
## Security model
- **Read-only.** No tool mutates state or accepts a caller-supplied URL.
- **Secret-path authentication.** The endpoint is `/mcp/<256-bit token>`.
Claude's connector UI accepts a URL but not custom headers, so the
credential lives in the path. Comparison is constant-time over SHA-256
digests; every failure (wrong token, missing token, unknown route) returns
an identical 404 — no oracle for guessing.
- **Inbound rate limiting.** ~60 requests/minute per IP (`cf-connecting-ip`,
set by Cloudflare and unspoofable by the client), checked *before* auth so
a flood can't spend CPU on token comparison. Backed by Workers KV; fails
open (allows) if KV isn't bound.
- **Outbound BLS budget guard.** A circuit breaker defaulting to 450 of BLS's
500/day registered-key quota — exhausting it fails the call *before* any
network request, protecting the real quota from a runaway loop.
- **Secrets never leave the server.** `BLS_API_KEY`, `FRED_API_KEY`, and
`MCP_PATH_TOKEN` live only as Workers secrets — never returned in a tool
response, never logged. `src/lib/logging.ts` redacts known secret values
and any `api_key=`/`registrationkey=` pattern from every log record; this
is asserted by unit tests, not just intended.
- **Known limitation:** Cloudflare's own platform request logs (and
`wrangler tail`) record the full request URL, including the path token —
this is outside application code's control. Don't share raw log output
publicly; rotate the token (`wrangler secret put MCP_PATH_TOKEN`, then
re-paste the new URL into Claude) if you ever do.
## Caching
Workers KV, two-tier: a "fresh" entry per the TTL table below, plus a 35-day
"stale backup" written alongside every success. If a live call fails or the
BLS budget is exhausted, the stale backup is served instead of failing
outright — flagged explicitly in the response's `limitations` field so
Claude never presents stale data as current without saying so.
| Data | TTL | Why |
|---|---|---|
| Employment Projections / OEWS | 30 days | Updated at most twice a year |
| BLS surveys / popular-series lists | 7 days | Near-static |
| FRED search | 24 hours | Stable |
| Monthly series (CES/CPS/JOLTS) | 6 hours | Monthly releases |
| `fred_get_latest` | 1 hour | Freshness matters most here |
Cache keys are derived only from the tool name and its arguments — never from
environment or secrets — so no key material can leak into a cache key.
## Project structure
```
src/
index.ts Worker entry: routing, auth, rate limiting
server.ts MCP server construction + tool registration
env.ts Env typing + secret names
errors.ts Typed error hierarchy (network/timeout/429/5xx/BLS-200-with-error-body)
sources/
http.ts Shared fetch: timeout, retry/backoff
bls.ts BLS v2 client
fred.ts FRED client
catalog/
occupations.json 1,113 SOC occupations -> EP series ID (build-generated, validated)
industries.json 423 EP industries -> series ID (build-generated, validated)
indicators.ts ~13 curated headline indicators (individually live-verified)
search.ts Shared token-matching + relevance-ranking search
tools/
source/ Thin passthrough tools
research/ Composed analysis tools
lib/
cache.ts Workers KV two-tier cache
ratelimit.ts BLS daily budget guard + inbound per-IP limiter
envelope.ts Response envelope: citations, timestamps, disclaimers
stats.ts Deterministic trend math
logging.ts Structured logs with secret redaction
scripts/
build-catalog.ts Regenerates + validates the occupation/industry catalog
test/
unit/ Mocked, run on every `npm test`
live/ Real API calls, opt-in via `npm run test:live`
```
## Local development
```bash
npm install
cp .dev.vars.example .dev.vars # fill in real keys for local testing
npx wrangler dev --port 8787
npm test # unit suite (mocked, no network)
npm run typecheck
npm run build:catalog # regenerate the occupation/industry catalog from BLS's own flat files
```
To verify against real BLS/FRED data locally (never commits or logs the keys):
```bash
BLS_API_KEY=your_key FRED_API_KEY=your_key npm run test:live
```
## Deployment
```bash
npx wrangler login
npx wrangler kv namespace create CACHE # one-time; paste the resulting id into wrangler.toml
npx wrangler secret put BLS_API_KEY
npx wrangler secret put FRED_API_KEY
npx wrangler secret put MCP_PATH_TOKEN # generate with: openssl rand -hex 32
npx wrangler deploy
curl https://<your-worker>.<your-subdomain>.workers.dev/health
```
Secrets and the KV binding persist across `wrangler deploy` — you only set
them once, not on every deploy.
## Known limitations
- Employment Projections and OEWS are single-reference-year snapshots, not
time series — every research tool explicitly detects and reports this
(`trend: null` with an explanatory limitation) rather than fabricating
a trend from one datapoint.
- No verified crosswalk exists between BLS Employment Projections' industry
codes and BLS's monthly CES industry employment series (different
classification schemes) — `analyze_industry_employment` covers long-run
outlook only; pair it with `fred_search_series` + `analyze_labor_market_trend`
for current monthly industry employment.
- SOC occupation codes change between Employment Projections vintages —
comparing an occupation across catalog rebuilds separated by a vintage
change is not reliable.
- The inbound rate limiter is a best-effort fixed-window counter (a
read-then-write race can under-count by a request or two under heavy
concurrency) — an accepted tradeoff for a low-volume personal connector,
not a precision guarantee.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues