mcp-wagewatch
This server provides MCP tools for searching and analyzing U.S. Department of Labor Wage and Hour Division (WHD) enforcement data, such as employer wage-theft history, back wages, penalties, and affected workers.
Search employers by name (
employer_violations): find enforcement cases matching an employer, optionally filtered by state, with back wages, penalties, employees affected, and violation counts.Get aggregate totals (
back_wages_summary): sum back wages, civil penalties, affected employees, and case counts across matching cases for an employer and/or state.Explore violations by state (
violations_by_state): list top enforcement cases in a state, optionally filtered by NAICS industry prefix.View full case detail (
case_detail): retrieve a complete case record, including the per-statute breakdown of which labor laws were cited (FLSA, MSPA, H-1B, FMLA, etc.).Find the largest cases (
top_cases): national, state, and/or date-window rankings of biggest back-wage cases without needing an employer name.Check repeat/willful violator flags (
flagged_employers): find cases carrying WHD's repeat/willful violator designation, filtered by state and flag type.Filter by date ranges: narrow searches with inclusive
found_after/found_beforeboundaries.Understand data currency: every response notes that results are historical concluded investigations, not proof of current compliance.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-wagewatchFind wage theft cases for Tyson Foods in Arkansas"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 |
|
| Enforcement cases matching the employer name, largest back wages first. Per case: employer, location, findings dates, back wages, civil penalties, employees affected, violation count. |
|
| Aggregate totals across matching cases: total back wages, total employees affected, total civil penalties, case count, findings date range. |
|
| Top cases in a state where a violation was found, ordered by back wages. Optional NAICS-prefix industry filter. |
|
| 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). |
|
| The largest cases by back wages nationally, in a state, and/or in a date window — no employer name needed. |
|
| Cases carrying the WHD repeat/willful violator flag, each case stating its own flag. |
Related MCP server: DOL WHD MCP
Data source
Base URL:
https://apiprod.dol.gov/v4Query path:
GET /get/WHD/enforcement/json(agencyWHD, endpointenforcement, tableWHD_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_objectquery parameter takes a JSON string withfield/operator/value(operatorseq,neq,gt,lt,in,not_in,like), composable withand/or. Paging vialimit/offset, ordering viasort_by/sort.Scope: one row per concluded compliance action since FY2005.
Sources:
DOL API User Guide (endpoint template, auth,
filter_objectsyntax): https://www.dataportal.dol.gov/pdf/dol-api-user-guide.pdfLive 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sum of statute-level |
|
|
|
|
|
Notes:
There is no single total-CMP-dollar column in WHISARD.
cmp_assd_cntis 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_penaltiessums those.back_wages_summaryaggregates client-side (the API does not expose a group-by), over up tomax_casesmatching rows (default 1000). Ifcappedis true the totals are a floor.cappedalso 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 — butlatest_findings_endis 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 atmax_cases2, the answer states 2003: the 2026 case was the smallest, arrived in the same page, and was dropped.data_currency.newest_findings_end_daterepublishes the same figure. A capped answer therefore says so in itsnote— the date is the newest among themax_caseslargest matching cases only, and the newest matching case may be far more recent. Narrow the query or raisemax_casesbefore citing it as a vintage.Name search: the endpoint's
LIKEis 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 byfindings_end_date, 485 (97%) carry a mixed-casetrade_nm. So the server searchestrade_nmandlegal_namefor three case variants of the term — as typed, uppercased, and title-cased — as oneorfilter, 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, becauseLIKEhas no case-insensitive form and DOL exposes noilike. A zero-result name search is worth one retry in the exact stored capitalization before it is read as "no cases found" —employer_violationsandback_wages_summarysay so in thenoteon an empty answer, and theemployerparameter'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 againsttrade_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
\his matched as a literal backslash-then-hrather than read as an escapedh, and there is noESCAPEclause in DOL'sfilter_objectto 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_afterandfound_beforeare inclusive: a case whose findings ended on the exact date is included. DOL's operators areeq/neq/gt/lt/in/not_in/likewith nogte/lte, andfindings_end_dateis 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-DDshape. The one-day shift goes throughDate.UTC, which rolls an out-of-range component over rather than rejecting it:2024-01-99becomes 2024-04-08 and0000-00-00becomes 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 buildgt 2024-04-07 AND lt 2024-02-02— unsatisfiable, answered 204, rendered as a cleancount: 0. A date that does not survive a UTC round trip is now refused before any request.naicsis checked against a closed domain too: 1 to 6 digits and nothing else, refused before any request. Same confident-zero shape asstateand 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 withnaic_cd722211, 722310, 722211), andtop_casesrenders that ascount: 0with nonoteat all, because it emits one only whenhas_moreis 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 byfindings_end_dateeverynaic_cdis all digits, 2 to 6 of them (length histogram6: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.stateis 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,STUSABcolumn, fetched 2026-09-14).{"field":"st_cd","operator":"eq","value":"ZZ"}is a legal filter that answers 204, which this server renders ascount: 0under the note that absence is not a clean record — so NU for NV, or MI for MN, read as "no wage enforcement published here". Likenaics, 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 onenot_infilter answers 204 (2026-09-14), so no WHISARD row carries anst_cdoutside it — checked red-capable first, since the same filter withNYremoved 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_objectvalues 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 + 1rows and reporthas_more, so a page of exactlylimitrows is never mistakable for a complete answer. All four also say so innote— the half a caller reading prose actually sees;top_casesused to carry nonoteat all andflagged_employers' note never varied with truncation, so the same page read as complete on two tools and truncated on two.back_wages_summarydoes the same withmax_casesand reportscapped, so a total whose true match count is exactlymax_casesis 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.jsnpm 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 |
|
| Total attempts per request (retries 429/5xx/transport). |
|
| Lifetime of a cached response. |
|
| 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 |
| back wages | employees | civil penalties |
| Kevin Misch Trucking & Excavating (Wheatfield, IN) |
| $41,918 | 16 | $0 |
| Kevin Misch Excavating (Crown Point, IN) |
| $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-KEYheader form answers 401);a zero-match filter answers HTTP 204 with an empty body;
numeric
filter_objectvalues 500 — strings work, including inside aninarray;LIKEis case-sensitive over mixed-case stored names (see the name-search note above);LIKEhonours 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_dateis a midnight timestamp, which is what makes a strictgt/ltbound drop its own day;a non-numeric
naicsprefix is a legal filter that answers 204 —{"value":"restaurant%"}returns nothing while{"value":"72%"}returnsnaic_cd722211, 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 MCPCounts, measured 2026-09-15:
App: 2273 lines (
server.ts1374,smoke.ts280,scripts/mcpb-probe.mjs217,scripts/lib/zip.mjs172,scripts/pack-probe.mjs129,scripts/pack-mcpb.mjs93,index.ts8).find . -type f \( -name '*.ts' -o -name '*.mjs' \) -not -path './node_modules/*' -not -path './dist/*' -not -path './test/*' | xargs wc -lTests: 1645 lines, 108 tests in 2 files.
find ./test -name '*.test.ts' | xargs wc -lfor the lines; the test count is vitest's. The grepgrep -cE '^\s*(it|test)\(' test/*.test.tsreads 100, because two groups are generated in a loop — the per-tool unknown-argument cases (oneit(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 1163until 2026-09-14 while its own quoted command answered2130 / 1247. Re-measured that day to2195 / smoke.ts 278— and the very next commit of the same round added seven lines tosmoke.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 |
| 3 tests, 47 of 50 passing at the time |
bad-value branch of | 4 tests, including "no tool result can carry the text 'Error: undefined'" |
drop the | 9 of the 11 unknown-argument tests; the coverage assertion and the accepts-declared-arguments test stay green, correctly |
drop | both |
| the willful-includes-RW test |
| 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 |
| the never-evict-what-was-just-written case, and only that one |
| the has_more note test |
| the all-four-list-tools note case |
| the all-four case and the flag-note case |
| the all-four case |
drop | the URL/key redaction test |
| the serialization test, and the spacing test with it |
| the spacing test |
the throttle gap re-chained onto the queue gate (true start-to-start spacing) | the spacing test, at −143ms |
| the aggregate-vintage case, written red first |
neutering the transposed-window check (WW-C) | the transposed-window case |
| both state-domain cases |
dropping | the accepts-the-whole-domain case, and only that one |
the calendar round-trip in | the rolled-over-date case and the rolled-over-bound case; the shape-only "January 2024" case stays green, correctly |
| the exact-max_cases case |
restoring back_wages_summary's | the unnamed-penalty-column case, under a mock that honours |
dropping either zero-result case hint (WW-B) | exactly that tool's case, one each |
a planted | the leaked-file check, exit 1 |
a planted type error in | each named with file:line, exit 1 |
revert WW-1's case variants (live | 2 checks red — |
restoring the backslash escaping in | the name filter's unescaped-term case, and nothing else. Re-measured 2026-09-15 after |
bypassing | the digits-only refusal case, and only that one |
| the falsy-state case, and only that one |
| the capped-population case, and only that one |
the same qualifier → emitted unconditionally (C1) | the not-capped case, and only that one |
| the four unrecognized-body cases; the 204-is-a-real-empty-answer case stays green, correctly |
the page-ceiling branch of | the ceiling case, and only that one |
forcing | 8 passed / 0 failed / 1 skipped, exit 1 — the same state exited 0 before |
dropping | 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 toolsback_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.
| Name | Required | Description | Default |
|---|---|---|---|
| state | No | 2-letter state code (e.g. "CA"). | |
| employer | No | Employer name or fragment to search for. | |
| max_cases | No | Max cases to aggregate over (1-1000, default 1000). |
TDQS
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.
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.
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.
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.
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.
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.).
| Name | Required | Description | Default |
|---|---|---|---|
| case_id | Yes | The WHD case id (from employer_violations / violations_by_state results). |
TDQS
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.
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.
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.
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.
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.
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).
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Max cases to return (1-100, default 20). | |
| state | No | Optional 2-letter state code to filter by (e.g. "NY"). | |
| employer | Yes | Employer name or fragment to search for (e.g. "tyson", "golden gate restaurant"). |
TDQS
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.
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.
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.
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.
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.
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).
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Max cases to return (1-100, default 20). | |
| naics | No | Optional NAICS code prefix to filter industry (e.g. "72", "722511"). | |
| state | Yes | 2-letter state code (e.g. "TX"). |
TDQS
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.
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.
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.
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.
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.
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.
4 tool updates
v1.0.0- First observed
back_wages_summary - First observed
case_detail - First observed
employer_violations - First observed
violations_by_state
TDQS
Scored across 4 tools
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.
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.
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.
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
Related MCP Connectors
MCP server for nonprofit financials via ProPublica — IRS Form 990 data for 1.8M+ nonprofits.
MCP server giving AI agents one-connection access to labor & financial-compliance data: DOL Form 550
MCP server for US nursing facility search and ownership lookup (NursingHomeDatabase).
Public MCP server for discovering open jobs. Search, filter, and get application links.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceUS + EU salary benchmarking, pay transparency compliance, and semantic endpoints. 1,400+ US occupations, 28 EU countries. MCP server for AI agents.MIT
- FlicenseBqualityDmaintenanceTypeScript MCP server for accessing Department of Labor enforcement data, OSHA inspections, and SAM.gov contract opportunities via natural language queries.28-
- AlicenseAqualityAmaintenanceOne-call Australian workplace gender data plumbing via WGEA — cited responses for workforce, pay-gap and inclusion analysis, not a data broker.6122 PyPIMIT
- AlicenseAqualityAmaintenanceMCP 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.611 npmMIT