Skip to main content
Glama
README.md
# mcp-wagewatch

MCP server over the U.S. Department of Labor Wage and Hour Division (WHD) enforcement dataset: employer wage-theft history, back wages owed, civil penalties, and affected-employee counts. Built for worker-justice nonprofits, legal-aid intake, and union researchers.

The data is the WHISARD compliance-action dataset (every concluded WHD compliance action since FY2005) served from the DOL Open Data API. This server wraps the raw column names (`trade_nm`, `bw_atp_amt`, `ee_violtd_cnt`, ...) into normalized tool outputs — field map below.

## Tools

| Tool | Arguments | Returns |
|------|-----------|---------|
| `employer_violations` | `employer` (required), `state`, `found_after`, `found_before`, `limit` | Enforcement cases matching the employer name, largest back wages first. Per case: employer, location, findings dates, back wages, civil penalties, employees affected, violation count. |
| `back_wages_summary` | `employer` and/or `state` (at least one), `max_cases` | Aggregate totals across matching cases: total back wages, total employees affected, total civil penalties, case count, findings date range. |
| `violations_by_state` | `state` (required), `naics`, `found_after`, `found_before`, `limit` | Top cases in a state where a violation was found, ordered by back wages. Optional NAICS-prefix industry filter. |
| `case_detail` | `case_id` (required) | Full record for one case, including the per-statute breakdown (which laws were cited: FLSA, MSPA, H-1B, FMLA, Davis-Bacon, child labor, and so on). |
| `top_cases` | `state`, `naics`, `found_after`, `found_before`, `limit` (all optional) | The largest cases by back wages nationally, in a state, and/or in a date window — no employer name needed. |
| `flagged_employers` | `state`, `flag` (`R` default, `W`, or `RW`), `limit` | Cases carrying the WHD repeat/willful violator flag, each case stating its own flag. `R` returns repeat **and** both-flagged (`RW`) cases, `W` returns willful and `RW`; `RW` returns only cases flagged both. WHD characterization, not a court finding. |

## Data source

- Base URL: `https://apiprod.dol.gov/v4`
- Query path: `GET /get/WHD/enforcement/json` (agency `WHD`, endpoint `enforcement`, table `WHD_enforcement`)
- Auth: a free `X-API-KEY`. The v4 API accepts it ONLY as a query parameter (the header form answers 401 — verified live), so the key rides the URL; be aware of that anywhere full request URLs are logged. This server's error messages never include the URL.
- Filtering: the `filter_object` query parameter takes a JSON string with `field` / `operator` / `value` (operators `eq`, `neq`, `gt`, `lt`, `in`, `not_in`, `like`), composable with `and` / `or`. Paging via `limit` / `offset`, ordering via `sort_by` / `sort`.
- Scope: one row per concluded compliance action since FY2005.

Sources:
- DOL API User Guide (endpoint template, auth, `filter_object` syntax): https://www.dataportal.dol.gov/pdf/dol-api-user-guide.pdf
- Live dataset catalog (agency/endpoint identifiers): https://apiprod.dol.gov/v4/datasets
- Dataset landing page: https://catalog.data.gov/dataset/wage-and-hour-division-compliance-action-data
- WHISARD column dictionary: https://github.com/jeremybmerrill/whd/blob/master/lib/data/whd_data_dictionary.csv

### Field map (WHISARD column to normalized output)

| WHISARD column | Normalized field |
|----------------|------------------|
| `case_id` | `case_id` |
| `trade_nm` (fallback `legal_name`) | `employer` |
| `legal_name` | `legal_name` |
| `street_addr_1_txt`, `cty_nm`, `st_cd`, `zip_cd` | `location.{street,city,state,zip}` |
| `naic_cd`, `naics_code_description` | `naics_code`, `naics_description` |
| `findings_start_date`, `findings_end_date` | `findings_start_date`, `findings_end_date` |
| `bw_atp_amt` (total back wages agreed to pay) | `back_wages` |
| `ee_violtd_cnt` (employees employed in violation) | `employees_affected` |
| `case_violtn_cnt` (total case violations) | `violations` |
| sum of statute-level `*_cmp_assd_amt` | `civil_penalties` |
| `cmp_assd_cnt` (count of assessments) | `cmp_assessment_count` (case_detail only) |
| `flsa_repeat_violator` (WHD repeat/willful flag) | `flsa_repeat_violator` (`case_detail` and `flagged_employers` only) |

Notes:
- There is no single total-CMP-dollar column in WHISARD. `cmp_assd_cnt` is a count of assessments; the dollar penalties live in per-statute columns (`flsa_cmp_assd_amt`, `mspa_cmp_assd_amt`, `h1b_cmp_assd_amt`, and so on). `civil_penalties` sums those.
- `back_wages_summary` aggregates client-side (the API does not expose a group-by), over up to `max_cases` matching rows (default 1000). If `capped` is true the totals are a floor.

  **`capped` also qualifies the DATE, and that half is easy to miss.** The query asks for the largest cases first, so a capped total is the strongest floor available — but `latest_findings_end` is then the newest date among *those* cases, which is an arbitrary subset in the date dimension and no sort can fix it. Over three matching rows ending 2001, 2003 and 2026 at `max_cases` 2, the answer states 2003: the 2026 case was the smallest, arrived in the same page, and was dropped. `data_currency.newest_findings_end_date` republishes the same figure. A capped answer therefore says so in its `note` — the date is the newest among the `max_cases` largest matching cases only, and the newest matching case may be far more recent. Narrow the query or raise `max_cases` before citing it as a vintage.
- **Name search: the endpoint's `LIKE` is case-SENSITIVE, and WHISARD stores names mixed-case.** Confirmed live 2026-09-14 by the pair that disproves the opposite claim this line used to make: `{"field":"trade_nm","operator":"like","value":"%KEVIN MISCH%"}` answers HTTP 204 with zero rows, while `"%Kevin Misch%"` answers HTTP 200 with case_id 1476714 and case_id 1419247. Of the 500 most recent rows by `findings_end_date`, 485 (97%) carry a mixed-case `trade_nm`. So the server searches `trade_nm` and `legal_name` for **three** case variants of the term — as typed, uppercased, and title-cased — as one `or` filter, wrapping each as `%term%`.

  Three variants are not every variant. A name stored with internal capitals (`ABC Plumbing`, `JBS USA`, `McDonald's`) is missed unless the term is typed in the stored casing, because `LIKE` has no case-insensitive form and DOL exposes no `ilike`. **A zero-result name search is worth one retry in the exact stored capitalization before it is read as "no cases found"** — `employer_violations` and `back_wages_summary` say so in the `note` on an empty answer, and the `employer` parameter's description says it up front.

  On the wildcards: **`%` and `_` in a search term are live wildcards and are sent through unescaped, because DOL's engine honours no escape character.** Six probes in one run, 2026-09-14, three bare/escaped pairs in the same minute against `trade_nm`:

  | value | HTTP | rows |
  |---|---|---|
  | `%Kevin Misch%` | 200 | 1476714, 1419247 |
  | `%Kevin Misc\h%` | 204 | — |
  | `%Kevin_Misch%` | 200 | 1476714, 1419247 |
  | `%Kevin\_Misch%` | 204 | — |
  | `%Kevin%Misch%` | 200 | 1476714, 1419247 |
  | `%Kevin\%Misch%` | 204 | — |

  Every escaped form answers zero. So `\h` is matched as a literal backslash-then-`h` rather than read as an escaped `h`, and there is no `ESCAPE` clause in DOL's `filter_object` to reach for instead. The server escaped `%`, `_` and `\` until this was settled, which turned any term carrying one of them into a pattern hunting a literal backslash — a confident "no cases found", this dataset's worst answer, triggered by a character this README had documented as safe. It no longer escapes: an unescaped `_` widens to any single character and `%` to any run, which over-matches. That is the recoverable direction — the extra rows arrive carrying their own employer names, so a caller can see them and narrow the term — and it is the only one available here.
- `found_after` and `found_before` are **inclusive**: a case whose findings ended on the exact date is included. DOL's operators are `eq`/`neq`/`gt`/`lt`/`in`/`not_in`/`like` with no `gte`/`lte`, and `findings_end_date` is a midnight timestamp, so each bound is shifted one day outward to the instant just outside the window.
- Both bounds must be a **real calendar date**, not just the `YYYY-MM-DD` shape. The one-day shift goes through `Date.UTC`, which rolls an out-of-range component over rather than rejecting it: `2024-01-99` becomes 2024-04-08 and `0000-00-00` becomes 1899-11-30, and the bound-order check compares the raw strings, so `{found_after: "2024-01-99", found_before: "2024-02-01"}` used to build `gt 2024-04-07 AND lt 2024-02-02` — unsatisfiable, answered 204, rendered as a clean `count: 0`. A date that does not survive a UTC round trip is now refused before any request.
- `naics` is checked against a **closed domain** too: **1 to 6 digits and nothing else**, refused before any request. Same confident-zero shape as `state` and the quietest instance of it — `{"field":"naic_cd","operator":"like","value":"restaurant%"}` is a legal filter that answers HTTP 204 (measured 2026-09-15, against `"72%"` answering 200 with `naic_cd` 722211, 722310, 722211), and `top_cases` renders that as `count: 0` with **no `note` at all**, because it emits one only when `has_more` is true. So the only prose a caller saw was the data-currency line saying no concluded published case was found, and a mistyped industry was quieter than a mistyped state. Bound on the domain, measured the same day: over the 500 most recent rows by `findings_end_date` every `naic_cd` is all digits, 2 to 6 of them (length histogram `6:472, 5:16, 4:9, 3:1, 2:2`), so 1-6 digits accepts every legal prefix of every stored code. This is the opposite call from the wildcard rule above, deliberately: a `%` or `_` rides through an employer NAME because over-matching a name is recoverable, while in a numeric code it is a typo that can only under-match.
- `state` is checked against a **closed domain** before it is sent: the 50 states, DC, and the territories — 57 codes, taken from the Census Bureau's FIPS/USPS reference ([`state.txt`](https://www2.census.gov/geo/docs/reference/state.txt), `STUSAB` column, fetched 2026-09-14). `{"field":"st_cd","operator":"eq","value":"ZZ"}` is a legal filter that answers 204, which this server renders as `count: 0` under the note that absence is not a clean record — so NU for NV, or MI for MN, read as "no wage enforcement published here". Like `naics`, it is a confident-zero shape over a closed domain, so it is refused rather than answered. The list is also the dataset's own domain, measured: all 57 as one `not_in` filter answers 204 (2026-09-14), so no WHISARD row carries an `st_cd` outside it — checked red-capable first, since the same filter with `NY` removed returns NY rows and a 204 is also what a broken filter looks like.
- **A zero-match filter answers HTTP 204 with an empty body** (confirmed live) — the server parses that as an empty result set, so "no concluded case found" is a real answer: `count: 0`, `has_more: false`, and the data-currency note that absence is not evidence of compliance.
- **All `filter_object` values must be JSON strings** — the engine answers a 500 "server error querying the dataset" for numeric values (`{"value": 0}` fails, `{"value": "0"}` works; confirmed live). Every filter value is string-coerced at serialization time.
- List tools request `limit + 1` rows and report `has_more`, so a page of exactly `limit` rows is never mistakable for a complete answer. All four also say so in `note` — the half a caller reading prose actually sees; `top_cases` used to carry no `note` at all and `flagged_employers`' note never varied with truncation, so the same page read as complete on two tools and truncated on two. `back_wages_summary` does the same with `max_cases` and reports `capped`, so a total whose true match count is exactly `max_cases` is a total rather than a floor.

## Install

### As a bundle (no terminal)

`npm run pack:mcpb` builds `haksanlulz-mcp-wagewatch-<version>.mcpb` — an [MCPB bundle](https://github.com/modelcontextprotocol/mcpb) (manifest spec 0.3). Clients that install MCPB bundles take the file directly and prompt for the DOL API key, which the manifest declares as a required, sensitive `user_config` field; nothing about the install involves editing JSON by hand.

The bundle carries `dist/`, `manifest.json`, `package.json`, and the **production dependency tree** — 3,523 files, 4.4 MB, measured 2026-09-14. That makes the dependency surface a shipped payload rather than a resolution-time detail, which is what `test/no-http-stack.test.ts` bounds: one runtime dependency, stdio transport only, no HTTP transport in the executed path.

`npm run verify:mcpb` packs the bundle, reads the ZIP back, confirms an independent unzip agrees, checks the manifest's version against `package.json` and that its `entry_point` is actually inside the archive, then extracts to a throwaway directory and launches the server the way `mcp_config` says to, asserting `tools/list` returns the documented six. It also refuses a bundle carrying a credential-shaped file (`.env`, `.npmrc`, `.netrc`, `.git-credentials`) anywhere in the tree, dependencies included — the root-anchored source-and-test patterns beside it cannot match anything the packer emits and are a guard against a future packer change, not a live check.

### As an npm package

Nothing to clone. Point your MCP client at it and npm fetches it on first run:

```json
{
  "mcpServers": {
    "wagewatch": {
      "command": "npx",
      "args": ["-y", "@haksanlulz/mcp-wagewatch"],
      "env": { "DOL_API_KEY": "your-dol-key" }
    }
  }
}
```

<details>
<summary>From source (contributors)</summary>

```bash
git clone https://github.com/haksanlulz/mcp-wagewatch
cd mcp-wagewatch
npm install
npm run build     # emits dist/; the published bin is dist/index.js
```

`npm start` runs the TypeScript directly via [`tsx`](https://github.com/privatenumber/tsx) without building.
</details>

## API key

Register for a free DOL Open Data API key at https://dataportal.dol.gov/registration, then expose it as `DOL_API_KEY`:

```
export DOL_API_KEY=your-key-here      # macOS / Linux
setx DOL_API_KEY your-key-here        # Windows (new shells)
```

Without the key the tools return an error naming the variable and the key-signup URL.

⚠️ **The key rides the query string, because the v4 API rejects the header form with a 401** (verified live). Anything that logs full outbound request URLs — a corporate proxy, a debugging HTTP client — will see it. What this server guarantees instead is that no message it returns carries the request URL or the key: an upstream error body that echoes the request, or a transport error that quotes the URL it was fetching, is redacted before it reaches a caller. Pinned by the test *"never lets the request URL or the key reach a caller in an error"*.

### Optional tuning

| Variable | Default | Meaning |
|----------|---------|---------|
| `DOL_HTTP_ATTEMPTS` | `3` | Total attempts per request (retries 429/5xx/transport). `1` disables retrying. |
| `DOL_CACHE_TTL_MS` | `86400000` (a day) | Lifetime of a cached response. `0` disables the cache. |
| `DOL_CACHE_MAX` | `300` | Cached responses kept before the oldest is evicted. A second, fixed ceiling of 20,000 total cached rows evicts alongside it. |

Each is read per use and validated: a value that is not a whole number in range is ignored, with a one-line note on stderr, and the default applies. A NaN attempt ceiling used to skip the request loop entirely and surface as `Error: undefined`, and a NaN TTL is a cache that never expires — which is this server's stated worst failure.

The cache holds whole WHISARD rows, and a row is about 2.8 KB across 110 columns (measured 2026-09-14), so a `back_wages_summary` answer at the default `max_cases` of 1000 is a ~2.8 MB entry. `DOL_CACHE_MAX` counts **entries**, which stopped bounding memory the day the aggregate dropped its column projection: 300 of those entries is a ~840 MB ceiling in a process meant to stay up. So eviction runs on two ceilings, whichever bites first — `DOL_CACHE_MAX` entries, and 20,000 total cached rows (~56 MB of JSON, about twenty full aggregate answers). The row ceiling is fixed rather than tunable, and never evicts the entry just written: one answer larger than the whole ceiling is still served from cache.

## Response shape

`employer_violations` with `{ "employer": "tyson", "state": "AR", "limit": 1 }` returns this shape. Values are illustrative (test-fixture data, not a live capture — see Verification state below; for a real capture, see the worked example):

```json
{
  "query": { "employer": "tyson", "state": "AR", "found_after": null, "found_before": null },
  "count": 1,
  "has_more": false,
  "cases": [
    {
      "case_id": "1234567",
      "employer": "TYSON FOODS INC",
      "legal_name": "TYSON FOODS INCORPORATED",
      "location": { "street": "2200 DON TYSON PKWY", "city": "SPRINGDALE", "state": "AR", "zip": "72762" },
      "naics_code": "311615",
      "naics_description": "Poultry Processing",
      "findings_start_date": "2021-01-01T00:00:00",
      "findings_end_date": "2022-01-01T00:00:00",
      "back_wages": 150000.5,
      "civil_penalties": 7500,
      "employees_affected": 88,
      "violations": 12
    }
  ],
  "data_currency": {
    "newest_findings_end_date": "2022-01-01T00:00:00",
    "note": "WHD publishes concluded investigations on a lag. These are historical enforcement records, not an employer's present compliance state, and an empty result means no concluded published case was found — not that none exists."
  }
}
```

`data_currency` is attached to **every** response and is computed from the rows actually returned, never from the clock: an answer is exactly as current as its newest record. When a result set carries no dates, `newest_findings_end_date` is `null` rather than absent.

## Worked example: an intake desk

A worker names an employer at a legal-aid intake desk. Four calls, in order. Every figure below is from a live capture on **2026-09-14**; re-running the calls reproduces the case ids.

**1. Find the cases.** `employer_violations { "employer": "Kevin Misch" }` → `count: 2`.

| case_id | employer | `findings_end_date` | back wages | employees | civil penalties |
|---|---|---|---|---|---|
| `1419247` | Kevin Misch Trucking & Excavating (Wheatfield, IN) | `2005-09-24T00:00:00` | $41,918 | 16 | $0 |
| `1476714` | Kevin Misch Excavating (Crown Point, IN) | `2007-05-27T00:00:00` | $30,438 | 23 | $11,069 |

Note what the search had to do to find them: WHISARD stores the name as `Kevin Misch Excavating`, and DOL's `LIKE` is case-sensitive, so `%KEVIN MISCH%` answers HTTP 204 — zero rows — for an employer with two published cases.

**2. Open the larger one.** `case_detail { "case_id": "1419247" }` → the per-statute breakdown is a single row: FLSA, 17 violations, $41,918 in back wages, 16 employees, no civil money penalty. Nothing under MSPA, H-2A, FMLA or child labor — this was a straight wage-and-hour case.

**3. Get the pattern.** `back_wages_summary { "employer": "Kevin Misch" }` → `case_count: 2`, `total_back_wages: 72356`, `total_employees_affected: 39`, `total_civil_penalties: 11069`, `earliest_findings_start: "2003-11-01T00:00:00"`, `latest_findings_end: "2007-05-27T00:00:00"`, `capped: false` (so the totals are totals, not a floor). The dates are printed as the API returns them, here and in the table above: every WHISARD date field is a midnight timestamp, and a caller that slices ten characters off one is writing a different type than it read.

**4. Check the flag.** `flagged_employers { "state": "IN", "flag": "RW" }` → case `1476714` is in the list, carrying `flsa_repeat_violator: "RW"` — WHD flagged the second investigation as both repeat and willful. The first case carries `"N/A"`.

The paragraph a caseworker pastes into an intake note:

> DOL's Wage and Hour Division has published two concluded investigations of Kevin Misch Trucking & Excavating (Wheatfield and Crown Point, IN): one closing 2005-09-24 with $41,918 in back wages for 16 employees, and one closing 2007-05-27 with $30,438 for 23 employees plus $11,069 in civil money penalties — $72,356 and 39 workers in total, all of it under the Fair Labor Standards Act. WHD flagged the second investigation as both repeat and willful, which is WHD's own characterization at the close of its investigation and not a court finding. These are concluded cases, published on a lag, so they describe what this employer was found to have done and not what it is doing now — and an employer with nothing here has not been cleared, only never published.

That last clause is the one to keep. An empty result from any of these tools means WHD has not published a concluded investigation naming the employer; it is not a clean record, and the `data_currency` note on every response says so.

## Verification state

Everything has been run live against the real API with a real key: `npm run smoke`, **9 passed / 0 failed / 0 upstream / 0 skipped, most recently 2026-09-15**. Seven contract facts were only discoverable live, all handled and regression-tested:

- the key is accepted **only as a query parameter** (the `X-API-KEY` header form answers 401);
- a zero-match filter answers **HTTP 204 with an empty body**;
- **numeric `filter_object` values 500** — strings work, including inside an `in` array;
- **`LIKE` is case-sensitive** over mixed-case stored names (see the name-search note above);
- **`LIKE` honours no escape character**, so `%` and `_` in a search term cannot be made literal and are sent through as wildcards (same note; six probes, three bare/escaped pairs);
- **`findings_end_date` is a midnight timestamp**, which is what makes a strict `gt`/`lt` bound drop its own day;
- **a non-numeric `naics` prefix is a legal filter that answers 204** — `{"value":"restaurant%"}` returns nothing while `{"value":"72%"}` returns `naic_cd` 722211, 722310, 722211 (2026-09-15), which is why the prefix is now checked against a digits-only domain.

**The live rung outstanding since fix round 3 has now been run: `npm run smoke`, 9 passed / 0 failed / 0 upstream / 0 skipped, 2026-09-15**, on the fix-round-4 tree (the only later commit is comment text). It exercises the two round-3 changes that altered what goes on the wire — the search term is no longer escaped, and `back_wages_summary` now sends `sort_by=bw_atp_amt&sort=desc` — so that argument is now a measurement. `employer_violations {employer: "Kevin Misch"}` returns case_id 1419247 and 1476714 live, which is the WW-1 regression the old content-free smoke could not have caught.

⚠️ **A second run started minutes later answered `429` on seven of nine checks: `1 passed / 0 failed / 7 upstream / 1 skipped`, exit 2.** That is the documented rate limiter, and it is the R5 exit-code discipline working — an incomplete run reports UPSTREAM and exits 2 rather than printing a pass. Read a `2` as "ask DOL again later", never as a verdict on this code, and leave real time between runs.

Column names were confirmed against live rows; the normalizer stays defensive regardless (unknown-shaped values coerce to `null`, and the CMP total scans every `*_cmp_assd_amt` column present).

Two things to know before running the live rung. Until 2026-09-14 the smoke asserted nothing about content — it printed counts and passed by not throwing, which is why a name search returning zero everywhere could not redden it; each check now asserts a count floor or an expected value, and a pinned case id carries its own stability argument (WHISARD records concluded cases; published rows do not change). And **DOL rate-limits hard**: a full smoke run plus a few ad-hoc queries is enough to draw a stretch of HTTP 429s with an empty body and no `Retry-After`. The smoke reports that as `UPSTREAM` and exits **2**, distinct from a failed assertion's **1**, because a 429 is a statement about DOL rather than about this code. Rerun it later rather than reading either as a pass.

Advisories, `npm audit` on **2026-09-14**: 2 moderate, both in the MCP SDK's HTTP-transport dependencies — hono (`<=4.13.4`) and qs (via express). Neither is reachable here: `test/no-http-stack.test.ts` pins that this server imports the stdio transport and nothing else. The .mcpb bundle **ships** those files rather than resolving them at install, so they are a payload, unreachable but present; `npm audit fix` is available and untaken, because a dependency bump is artifact-affecting and belongs to its own change.

## Testing

Two tiers, split by script. No test markers; the split is which command you run.

```
npm test           # offline: vitest, fetch mocked with the documented response shapes, no key needed
npm run smoke      # live: one real call per tool against the DOL API (needs DOL_API_KEY; skips and exits 0 without it)
                   #   also a manual CI job -- Actions > CI > Run workflow -- which fails rather than skips when the secret is absent
npm run typecheck   # both tsconfigs: the shipped surface, then smoke.ts and test/ too
npm run verify:pack  # packs the tarball, installs it in a throwaway project, launches through the bin shim, speaks MCP
npm run verify:mcpb  # packs the .mcpb bundle, extracts it cold, launches the manifest's entry_point, speaks MCP
```

Counts, measured 2026-09-15:

- App: 2273 lines (`server.ts` 1374, `smoke.ts` 280, `scripts/mcpb-probe.mjs` 217, `scripts/lib/zip.mjs` 172, `scripts/pack-probe.mjs` 129, `scripts/pack-mcpb.mjs` 93, `index.ts` 8). `find . -type f \( -name '*.ts' -o -name '*.mjs' \) -not -path './node_modules/*' -not -path './dist/*' -not -path './test/*' | xargs wc -l`
- Tests: 1645 lines, 108 tests in 2 files. `find ./test -name '*.test.ts' | xargs wc -l` for the lines; the test count is vitest's. The grep `grep -cE '^\s*(it|test)\(' test/*.test.ts` reads 100, because two groups are generated in a loop — the per-tool unknown-argument cases (one `it(` for six tests) and the unrecognized-body cases (one for four).

  ⚠️ This block is the one thing on the page that goes stale without anyone touching it, **and it has now done so twice running.** It read `2046 / server.ts 1163` until 2026-09-14 while its own quoted command answered `2130 / 1247`. Re-measured that day to `2195 / smoke.ts 278` — and the very next commit of the same round added seven lines to `smoke.ts`, leaving it wrong again by two before the round ended, under the warning you are reading. The lesson is not "remember harder": **this block is measured LAST, after the round's final code commit, or it is measured at a tree that no longer exists.** Re-run the two commands rather than trusting the figures — a line count is the cheapest claim on this page to check and the only one that rots on its own.

What the offline suite covers, by layer: `test/server.test.ts` runs a real MCP client and server over an in-memory transport with fetch stubbed, and asserts the request grammar (filter_object shape, LIKE metacharacters passed through unescaped and the case variants, string-coerced values and array values, inclusive date bounds, limit+1 probe row, query-param key, User-Agent, abort signal), the argument contract (unknown keys refused before any network call, one case per tool; a two-letter code outside the federal state domain refused on every tool that takes one, and all 57 accepted; a falsy non-string state refused on all five rather than answered nationally by one, while an omitted, empty or null state stays the national query; a non-digit `naics` refused on both tools that take one, and every stored code length accepted), the response normalization (field map, per-statute penalty sums, the repeat/willful flag, 204-empty as zero matches, and every unrecognized 200 body rejected rather than read as zero cases -- an error envelope, a bare JSON string, a number, a boolean, a literal null), the retry policy (3 attempts on 5xx, none on 4xx or a non-JSON body), the environment knobs (a bad value falls back to the documented default rather than killing the retry loop or freezing the cache), the outbound throttle (concurrent calls serialized, and the gap held between one response settling and the next request going out, measured against a mock that takes real time), the redaction of the request URL and key out of every error message, the response cache including LRU eviction on both its entry and its row ceiling, the refusal of a transposed date window and of a shape-legal date that is not a real calendar date, the truncation note on every list tool including the sentence it switches to at the page ceiling, the aggregate asking for the largest cases first, the case-coverage note on a zero-result name search, and the data_currency spec on every result shape including the aggregate's. `test/no-http-stack.test.ts` pins the dependency surface: stdio transport only, one runtime dependency. The live smoke, `verify:pack` and `verify:mcpb` cover what mocks cannot: the DOL contract, the published npm artifact and the .mcpb bundle.

Mutation probes, 2026-09-14, each restored after the run:

| Mutation | Reddened |
|---|---|
| `shiftIsoDate(after, -1)` → `0` (inclusive date bound) | 3 tests, 47 of 50 passing at the time |
| bad-value branch of `envInt` → `false` | 4 tests, including "no tool result can carry the text 'Error: undefined'" |
| drop the `validateArgs` call | 9 of the 11 unknown-argument tests; the coverage assertion and the accepts-declared-arguments test stay green, correctly |
| drop `top_cases`' NAICS prefix filter | both `top_cases` filter tests |
| `FLAG_SEARCHES.W` → `["W"]` | the willful-includes-RW test |
| `cacheMax()` → `Infinity` | the LRU eviction test |
| drop the row half of the eviction condition (WW-M) | the total-rows eviction case; the entry-count case stays green, correctly |
| `cache.size > 1 && rowTotal > CACHE_MAX_ROWS` → `rowTotal > CACHE_MAX_ROWS` (WW-M) | the never-evict-what-was-just-written case, and only that one |
| `violations_by_state`' truncation note → `undefined` | the has_more note test |
| `top_cases`' truncation note → `undefined` (F1) | the all-four-list-tools note case |
| `flagged_employers` back to its static note only (F1) | the all-four case and the flag-note case |
| `employer_violations`' truncation note → `undefined` (F1) | the all-four case |
| drop `redactSecrets` from the error path | the URL/key redaction test |
| `queue.then(fn, fn)` → `fn()` (throttle serialization) | the serialization test, and the spacing test with it |
| `THROTTLE_MS` → `0` | the spacing test |
| the throttle gap re-chained onto the queue gate (true start-to-start spacing) | the spacing test, at −143ms |
| `newestFindingsDate` scanning `findings_end_date` only (WW-A) | the aggregate-vintage case, written red first |
| neutering the transposed-window check (WW-C) | the transposed-window case |
| `STATE_CODES.has(code)` → `false` (WW-N) | both state-domain cases |
| dropping `UM` from `STATE_CODES` (WW-N) | the accepts-the-whole-domain case, and only that one |
| the calendar round-trip in `normDate` → `if (false)` (WW-L) | the rolled-over-date case and the rolled-over-bound case; the shape-only "January 2024" case stays green, correctly |
| `capped: fetched.length > cap` → `rows.length >= cap` (WW-G) | the exact-max_cases case |
| restoring back_wages_summary's `fields` allow-list (WW-J) | the unnamed-penalty-column case, under a mock that honours `fields` |
| dropping either zero-result case hint (WW-B) | exactly that tool's case, one each |
| a planted `.env` inside a bundled dependency (WW-F, `npm run verify:mcpb`) | the leaked-file check, exit 1 |
| a planted type error in `smoke.ts`, and one in `test/` (WW-I, `npm run typecheck`) | each named with file:line, exit 1 |
| revert WW-1's case variants (live `npm run smoke`) | 2 checks red — `employer_violations` "expected >= 2 cases, got 0" and `back_wages_summary` "expected >= 100 cases for Walmart, got 1" at $0 total — exit 1 |
| restoring the backslash escaping in `escapeLike` (R1) | the name filter's unescaped-term case, and nothing else. Re-measured 2026-09-15 after `naics` became a digits-only domain (C2): the NAICS case can no longer carry a metacharacter, so it stays green and this row no longer names it |
| bypassing `normNaics` at both call sites (C2) | the digits-only refusal case, and only that one |
| `employer_violations`' state guard back to `if (args.state)` (C4) | the falsy-state case, and only that one |
| `back_wages_summary`'s capped vintage qualifier → never emitted (C1) | the capped-population case, and only that one |
| the same qualifier → emitted unconditionally (C1) | the not-capped case, and only that one |
| `extractRows` back to rejecting only object bodies (R2) | the four unrecognized-body cases; the 204-is-a-real-empty-answer case stays green, correctly |
| the page-ceiling branch of `truncationNote` made unreachable (R3) | the ceiling case, and only that one |
| forcing `smoke.ts` down its skip branch (R5, live `npm run smoke`) | 8 passed / 0 failed / 1 skipped, **exit 1** — the same state exited 0 before |
| dropping `sort_by` from `back_wages_summary`'s query (R6) | the largest-cases-first case, and only that one |

Superseded probe, 2026-09-11: dropping the `%` escape from `escapeLike` failed exactly one test, `employer_violations > escapes LIKE metacharacters in the employer term so they match literally` (37 of 38 passed). Kept because of what it shows — that test and that probe were both green over the defect. They pinned that the request carried the escapes, which it did; neither could say whether DOL read them, and DOL does not. A request-grammar assertion measures the request and never the contract.

One note on running probes here: `npx vitest run --reporter=basic` exits 1 without running anything on vitest 4, so a probe wired that way reports every mutation as red whether or not the suite noticed. Use the default reporter and read the per-test FAIL lines.

The call-count assertions — 26 in the source as of 2026-09-14, up from the 9 audited on 2026-09-11 — were each kept for pinning a contract, not for recording that a function ran: no network call before validation passes (the largest group, one per tool since arguments are now checked up front), retry counts under the attempt ceiling, cache dedupe and LRU eviction. Policy: assert behavior and payloads, not that a function was called.

## AI assistance

This project was built with AI assistance (Claude). Correctness was established by the mocked vitest suite (a real MCP client/server pair over an in-memory transport, fetch stubbed with the real response shapes), `npm run typecheck`, and live runs of every tool against the real DOL API with a real key — which is where the query-param auth, 204-empty, and string-only-filter contract facts came from. The author reviewed the code and is accountable for it.

## License

MIT. See [LICENSE](LICENSE). Public U.S. government data from the U.S. Department of Labor. Unofficial, not affiliated with DOL.

TDQS

A4.2/5.0

Scored across 4 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: employer_violations searches by employer name, back_wages_summary provides aggregate statistics, violations_by_state lists top cases in a state with optional industry filter, and case_detail gives full details for a specific case. No overlap in functionality.

Naming Consistency5/5

All tool names use consistent snake_case format and follow a clear pattern: noun_descriptor (employer_violations, back_wages_summary, case_detail) and noun_by_noun (violations_by_state). There is no mixing of conventions or confusing naming.

Tool Count5/5

With 4 tools, the server is well-scoped for the domain of WHD enforcement cases. Each tool serves a necessary function (search, aggregate, state-level view, detail) without being too few or too many.

Completeness5/5

The tool surface covers the essential operations for the domain: searching by employer, viewing top cases by state, getting aggregate summaries, and retrieving full case details. There are no obvious gaps or dead ends; the tools work together to provide a complete workflow.

Maintenance

ActivityActive
ResponsivenessNo issues