Skip to content
Conduit

mcp server

mcp-wagewatch

MCP server over the U.S. DOL Wage and Hour Division enforcement dataset (employer wage-theft history and back wages owed) for nonprofits, legal aid, and worker-justice organizers.

haksanlulz/mcp-wagewatchcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • Node.js 20 or newer
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "mcp-wagewatch": {
      "command": "npx",
      "args": [
        "-y",
        "@haksanlulz/mcp-wagewatch"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 26, 2026

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

ToolArgumentsReturns
employer_violationsemployer (required), state, found_after, found_before, limitEnforcement 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_summaryemployer and/or state (at least one), max_casesAggregate totals across matching cases: total back wages, total employees affected, total civil penalties, case count, findings date range.
violations_by_statestate (required), naics, found_after, found_before, limitTop cases in a state where a violation was found, ordered by back wages. Optional NAICS-prefix industry filter.
case_detailcase_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_casesstate, 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_employersstate, flag (R default, W, or RW), limitCases carrying the WHD repeat/willful violator flag, each case stating its own flag. R returns repeat and both-flagged (RW) cases, W returns willful and RW; RW returns only cases flagged both. WHD characterization, not a court finding.

Data source

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

Sources:

Field map (WHISARD column to normalized output)

WHISARD columnNormalized field
case_idcase_id
trade_nm (fallback legal_name)employer
legal_namelegal_name
street_addr_1_txt, cty_nm, st_cd, zip_cdlocation.{street,city,state,zip}
naic_cd, naics_code_descriptionnaics_code, naics_description
findings_start_date, findings_end_datefindings_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_amtcivil_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, for both ends of the range: earliest_findings_start and latest_findings_end come from the max_cases largest matching cases only, so the oldest matching case may be far older and the newest may be far more recent. Narrow the query or raise max_cases before citing either date.

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

    valueHTTProws
    %Kevin Misch%2001476714, 1419247
    %Kevin Misc\h%204none
    %Kevin_Misch%2001476714, 1419247
    %Kevin\_Misch%204none
    %Kevin%Misch%2001476714, 1419247
    %Kevin\%Misch%204none

    Every escaped form answers zero. So \h is matched as a literal backslash-then-h, never as an escaped h, and there's 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, and that's the recoverable direction (the extra rows arrive carrying their own employer names, so a caller can see them and narrow the term). It's also 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 instead of 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 doesn't 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's a confident-zero shape over a closed domain, so it's refused. 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 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 reported as a total and not as 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, 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 can't match anything the packer emits; they're a guard against a future packer change.

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

VariableDefaultMeaning
DOL_HTTP_ATTEMPTS3Total attempts per request (retries 429/5xx/transport). 1 disables retrying.
DOL_CACHE_TTL_MS86400000 (a day)Lifetime of a cached response. 0 disables the cache.
DOL_CACHE_MAX300Cached 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, 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_idemployerfindings_end_dateback wagesemployeescivil penalties
1419247Kevin Misch Trucking & Excavating (Wheatfield, IN)2005-09-24T00:00:00$41,91816$0
1476714Kevin Misch Excavating (Crown Point, IN)2007-05-27T00:00:00$30,43823$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. That's $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, not what it is doing now. An employer with nothing here hasn't 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's the R5 exit-code discipline working: an incomplete run reports UPSTREAM and exits 2 instead of 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 and not about this code. Rerun it later; neither is 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 instead of resolving them at install, so they're 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-25:

  • App: 2300 lines (server.ts 1384, smoke.ts 280, scripts/mcpb-probe.mjs 217, scripts/lib/zip.mjs 172, scripts/pack-probe.mjs 146, 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: 1692 lines, 110 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 102, 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 instead of 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.

Every fix was mutation-probed: the code was put back to the defect and its own tests had to go red, with the rest staying green. Thirty-six probes are on record, including ones against the live smoke, the bundle probe and the typecheck. The table is in GAUNTLET.md, under Mutation-probe record.

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: 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, never bare invocation.

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.

↑↓ navigate openesc close