Skip to main content
Glama

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.

Related MCP server: DOL WHD MCP

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:

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, 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 (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:

{
  "mcpServers": {
    "wagewatch": {
      "command": "npx",
      "args": ["-y", "@haksanlulz/mcp-wagewatch"],
      "env": { "DOL_API_KEY": "your-dol-key" }
    }
  }
}
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 without building.

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):

{
  "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 envIntfalse

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_ROWSrowTotal > 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_MS0

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 normDateif (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 > caprows.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. Public U.S. government data from the U.S. Department of Labor. Unofficial, not affiliated with DOL.

Available Tools

4 tools
back_wages_summaryA

Aggregate total back wages, employees affected, civil penalties, and case count for an employer-name and/or state query. Computed client-side over up to 1000 matching cases. At least one of employer or state is required.

ParametersJSON Schema
NameRequiredDescriptionDefault
stateNo2-letter state code (e.g. "CA").
employerNoEmployer name or fragment to search for.
max_casesNoMax cases to aggregate over (1-1000, default 1000).

TDQS

A4.2/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Without annotations, the description discloses that computation is client-side over up to 1000 matching cases, adding transparency. However, it does not address rate limits, authentication needs, or what happens if more cases exist beyond the cap.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two concise sentences, front-loading the core purpose and following with constraints. No unnecessary words.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given no output schema, the description lists what the tool returns (aggregate totals, employees affected, penalties, case count). It is sufficient for a simple aggregation tool, though explicit output structure could be added.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100% but the description adds value by explaining the dependency (at least one of employer/state required) and stating the default and range for max_cases, which are not in the schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the verb 'aggregate' and the resources (back wages, employees, penalties, case count) for an employer and/or state query. It distinguishes from sibling tools that likely provide detailed records or other views.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description specifies that at least one of employer or state is required, providing clear when-to-use guidance. It does not explicitly mention alternatives but implies usage for aggregated summaries rather than detailed case retrieval.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

case_detailA

Full record for one WHD enforcement case by its case id, including the per-statute breakdown (which labor laws were violated: FLSA, MSPA, H-1B, FMLA, Davis-Bacon, child labor, etc.).

ParametersJSON Schema
NameRequiredDescriptionDefault
case_idYesThe WHD case id (from employer_violations / violations_by_state results).

TDQS

A3.6/5.0
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description must carry the full burden of behavioral disclosure. It only states that a full record is returned but fails to mention any safety guarantees (e.g., read-only), error handling for invalid case IDs, data freshness, or authentication requirements. The description is minimal on behavioral traits.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single sentence of 25 words, front-loaded with the core action and result. It wastes no words and is easily scannable.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple, one-parameter lookup tool with no output schema, the description adequately conveys the main purpose and key output feature (per-statute breakdown). However, it could be slightly improved by clarifying that it returns a single object or how it handles missing case IDs. Given the low complexity, the description is mostly complete.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The input schema covers 100% of parameters and already describes 'case_id' as the WHD case ID from other tool results. The tool description adds no new parameter information beyond the schema; it only mentions 'by its case id', which is redundant. Baseline 3 is appropriate since the schema is complete.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool retrieves a full record for a single WHD enforcement case by case ID, and explicitly mentions the per-statute breakdown, which distinguishes it from sibling tools like employer_violations (list) or back_wages_summary (aggregation). The verb 'get' is implicit, and the resource is well-defined.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage when a detailed per-statute breakdown for a specific case is needed, but it lacks explicit guidance on when not to use this tool (e.g., for bulk queries) or alternatives like sibling tools. The context is implied but not stated.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

employer_violationsA

Search WHD enforcement cases by employer name (matches trade name or legal name), optionally filtered to a state. Returns each case with location, findings dates, back wages, civil penalties, and employees affected. Ordered by back wages (largest first).

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNoMax cases to return (1-100, default 20).
stateNoOptional 2-letter state code to filter by (e.g. "NY").
employerYesEmployer name or fragment to search for (e.g. "tyson", "golden gate restaurant").

TDQS

A4.2/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Without annotations, the description carries full burden. It discloses ordering behavior ('ordered by back wages, largest first'), return fields (location, findings dates, etc.), and matching behavior (trade name or legal name). No mention of rate limits or side effects, but for a read-only search this is adequate.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two sentences with no filler. First sentence states the core function and inputs; second sentence adds ordering and output fields. Information is front-loaded and every word is necessary.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple search tool with 3 parameters, no output schema, and no annotations, the description covers functionality, filtering, ordering, and return structure. It does not mention pagination but the limit parameter partially addresses that. No major gaps for the given complexity.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, so baseline is 3. The description adds value by explaining that employer name matches trade name or legal name, that state is optional, and that results are ordered by back wages. This contextualizes the parameters beyond the schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description uses a specific verb-resource pair ('Search WHD enforcement cases') with clear scoping (by employer name, optionally filtered by state). It distinguishes itself from siblings like 'violations_by_state' (which filters only by state) and 'case_detail' (which gets a single case) by focusing on employer name search.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage for searching by employer name but does not explicitly state when to use this tool vs siblings like 'back_wages_summary' or 'violations_by_state'. No 'when not to use' guidance is provided.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

violations_by_stateA

Top WHD enforcement cases in a state (cases where a violation was found), ordered by back wages owed. Optionally filter to an industry by NAICS code prefix (e.g. "72" for accommodation and food services).

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNoMax cases to return (1-100, default 20).
naicsNoOptional NAICS code prefix to filter industry (e.g. "72", "722511").
stateYes2-letter state code (e.g. "TX").

TDQS

A3.6/5.0
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are present, so the description carries the full burden. It does not disclose behavioral traits such as rate limits, authentication needs, or handling of empty results. The description only covers basic functionality.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two short sentences with no wasted words. It front-loads the core purpose ('Top WHD enforcement cases in a state') and adds the optional filter concisely.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a listing tool with 3 parameters and no output schema, the description covers purpose and key filters. It lacks details about output format (fields returned) but provides sufficient context for selection.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so the baseline is 3. The description adds ordering context ('ordered by back wages owed') and reinforces the NAICS filter with an example, but does not add significant new meaning beyond the schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool returns top WHD enforcement cases in a state with violations, ordered by back wages owed, and optionally filtered by NAICS code. It effectively communicates the specific resource and action.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage for retrieving violation cases ordered by back wages, but does not explicitly state when to use this tool over siblings like employer_violations or case_detail. No when-not or alternative guidance is provided.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 4 tool updatesv1.0.0
    • First observedback_wages_summary
    • First observedcase_detail
    • First observedemployer_violations
    • First observedviolations_by_state

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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    US + EU salary benchmarking, pay transparency compliance, and semantic endpoints. 1,400+ US occupations, 28 EU countries. MCP server for AI agents.
    MIT
  • F
    license
    B
    quality
    D
    maintenance
    TypeScript MCP server for accessing Department of Labor enforcement data, OSHA inspections, and SAM.gov contract opportunities via natural language queries.
    28
    -
  • A
    license
    A
    quality
    A
    maintenance
    One-call Australian workplace gender data plumbing via WGEA — cited responses for workforce, pay-gap and inclusion analysis, not a data broker.
    6
    122 PyPI
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    MCP server for NYC housing data, enabling tenant organizers and legal-aid intakes to pull building violations, complaints, ownership, litigation, and evictions. It wraps six city datasets from NYC Open Data.
    6
    11 npm
    MIT