Skip to main content
Glama
harperbrian

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues