Andrew Fribush

← All posts

Testing API Wrappers for Silent Empty Results

· October 12, 2026 · 12 min read

The live test suite for an MCP server over US government data APIs calls every tool outside the bulk-ingest modules once against the real agency and checks that nothing threw. I added a second check: unless a tool is on a short allow list, its canned arguments point at data that exists, so an empty answer fails the test even though nothing errored. The suite tags that failure [SILENT EMPTY]. With that one assertion the suite catches 8 of the 12 upstream breaks one hardening pass found, where checking for errors alone caught 2.

The case it exists for is the CMS provider-data module. All four of its tools had been returning [] for every query. The DKAN metastore had stopped honoring show-reference-ids= with an empty value, so the distribution-UUID lookup resolved to an empty string and every query short-circuited to an empty list. Nothing threw, so the smoke suite stayed green, and I found it in a hands-on audit, calling each tool with arguments the suite had never used.

In a wrapper over other people's APIs, the most common failure is a well-formed empty or wrong result that looks like success and throws no error, so a test suite that only checks for errors passes it every time. The server had 352 tools across 42 modules on the audit date, and 387 by the end of the same audit. The suite that calls them runs nightly against the live APIs. The claims here are about the test suite: what one hardening pass found, what the guard now catches, and what it can't. Nothing in them is about the agencies' data or the MCP protocol.

The guard's first run flagged 10 tools, and they came down to one real bug and nine stale canned arguments. The real bug: the two committee-report list endpoints at Congress.gov answer with the key reports, and the SDK read committeeReports, which is the detail endpoint's key, so both list tools had always returned empty. The stale arguments: a bill constant pointed at 118 HR 3684, a minor bill, instead of the 117th Congress infrastructure act, so every bill-votes and related-bills probe was asking about a bill with nothing attached. A clinical-trial id had no posted results. A VIN was an invalid example. A GSA contract number had expired. An FEC contributions call ran unfiltered, which the tool refuses by design. Each stale argument needed a researched replacement before the guard would pass.

Twelve Breaks, and Eleven of Them Answered 200

The full catalogue from the pass is the figure below, with what came back under each break. It files each row under the weakest assertion that would have caught it: a suite that makes a given assertion also makes every weaker one, so choosing a rung lights its rows and every row above it.

fig. 2.5.1 · twelve API failures in a 352-tool server, filed under the weakest test assertion that catches each.
WITH THE SILENT-EMPTY GUARD THE SUITE CATCHES 8 OF 12 BREAKS; CHECKINGFOR ERRORS ALONE CAUGHT 2ONE ROW IS ONE ASSERTION A TEST SUITE CAN MAKE, WEAKEST FIRST; ONE SQUARE IS ONE OFTHE 12 BREAKS, LIT WHEN THAT ASSERTION, OR A WEAKER ONE, CATCHES IT. CLICK ANASSERTION.CHECK FOR ERRORS ONLY · 2 OF 12ASSERT NON-EMPTY · 6 OF 12ASSERT NO ALL-EMPTY ROWS · 8 OF 12A HUMAN READS THE OUTPUT · 12 OF 12THE BREAK, UNDER THE WEAKEST ASSERTION THAT CATCHES ITWHAT THE SUITE SAWCHECK FOR ERRORS ONLYBEA WITHOUT A KEY: HTTP 200 WITH AN EMPTY BODY200 · EMPTY BODY, THE PARSER THREWOPENFDA, 25 TOOLS: ZERO MATCHES THREW AN ERROR404 · THROWNASSERT NON-EMPTYCMS PROVIDER DATA, 4 TOOLS: EVERY QUERY EMPTY200 · []CONGRESS COMMITTEE REPORT LISTS: ALWAYS EMPTY200 · []USPTO DECISIONS, 2 TOOLS: EMPTY FOR EVERY QUERY200 · NO DECISIONS FOUNDCFPB SUGGEST COMPANY: EMPTY FOR EVERY INPUT200 · []ASSERT NO ALL-EMPTY ROWSUSASPENDING OVER TIME: 37 PERIODS OF EMPTY ROWS200 · 37 × []CONGRESSIONAL RECORD: ROWS OF EMPTY OBJECTS200 · [{}, {}, …]A HUMAN READS THE OUTPUTBTS MONTHLY STATS: 2 OF EVERY 3 MONTHS DROPPED200 · 1 ROW IN 3, RIGHT SHAPEEDGAR "SINCE LAST WEEK": THE CORPUS BACK TO 2018200 · 10,000 ROWS, RIGHT SHAPERESOLVERS: CYRILLIC "США" RESOLVED TO CUBA200 · ONE PLAUSIBLE ROWCDC STATE FILTERS, 4 TOOLS: EMPTY ON ONE SPELLING200 · [] FOR "OH" OR "OHIO"

A suite that makes an assertion also makes every weaker one, so choosing a rung lights its rows and every row filed above it. The guard’s first run flagged 10 tools: 1 real bug and 9 stale canned arguments, fixed by maintaining the arguments rather than relaxing the guard.

source: us-gov-open-data-mcp docs/tool-audit.md lines 17-19, 37-39, 65-67, 80-84, 101-103, 157-158, 303-305, 347-352, 369-372, 408-412, 451-456; commits 1c336a4, fb2c96a, 8f16e46, a1ae253, 9177aa1, ef7e443, 6e0c7ee (repo 796ea9d) · silentEmpties.data.ts

Eleven of the twelve rows were an HTTP 200. Two of them were well-formed answers in the wrong shape: the CFPB suggest endpoint returns a bare string array where the tool expected an Elasticsearch suggest envelope, and the congressional record answers in PascalCase (Issue, Volume, PublishDate, Links), so its rows came back as empty objects. openFDA was the one 404, and it's the inverse case: it reports zero matches as 404 NOT_FOUND, so all 25 of its tools threw an error where an empty result was the right answer. It and BEA, whose empty 200 body made the JSON parser throw, are the 2 rows the weakest assertion the suite makes could catch, because a thrown error is the one thing "did it throw" sees.

The BTS dataset carries placeholder rows for months not yet published, and the tool filtered those out by checking whether general_economic_indicators was present. That column is quarterly GDP, so it exists in one month of every three. The filter dropped the other two, and monthly statistics came back quarterly, with the right shape and a monthly label. I replaced it with a future-date guard, which is the dataset's actual placeholder case.

The USPTO row couldn't be found until a key arrived, because the module had been audited only for clean keyless errors. Two decisions tools read patentTrialDecisionDataBag and petitionDecisionBag, and the API returns patentTrialDocumentDataBag and petitionDecisionDataBag. Both answered "No decisions found" for every query. With the right keys, the audit's verification queries found 19,838 trial decisions and 7,489 petition decisions, and both tools are now in the live-smoke arguments as regression guards.

EDGAR full-text search drops the start date when no end date is given, and the reverse. A query for filings since 2026-08-25 returned 10,000 results reaching back to 2018, with the right columns. A missing bound now defaults, the end date to today and the start date to the 2001 full-text-search epoch, and in the September verification the same lone start date returned 20 filings, the newest from 2026-08-31. (Congress.gov and the CFPB handle a lone bound correctly, so the quirk is EDGAR's.)

Assert Nonempty Results for Known-Good Queries

The guard runs after the throw check and after a check that the payload isn't error-shaped (a summary beginning error, failed, unauthorized, forbidden or invalid api key). It takes the parsed response and walks a ladder of reasons the result could be empty. The first reason that applies becomes the failure message. This is the whole of it, trimmed from tests/live/tools.smoke.test.ts, with long lines wrapped:

ts
if (!ALLOWED_EMPTY.has(tool.name) && res && typeof res === "object") {  const r: any = res;  const allRowsEmpty = (arr: unknown[]) =>    arr.length > 0 && arr.every(x =>      Array.isArray(x) ? x.length === 0      : (x && typeof x === "object"          ? Object.keys(x).length === 0 : false));  const emptyBecause =    r.dataType === "empty" ? "dataType=empty"    : Array.isArray(r.data?.rows) && r.data.rows.length === 0      ? "0 rows"    : Array.isArray(r.data?.items) && r.data.items.length === 0      ? "0 items"    : Array.isArray(r.data?.rows) && allRowsEmpty(r.data.rows)      ? `${r.data.rows.length} rows, all empty (field-mapping bug?)`    : Array.isArray(r.data?.items) && allRowsEmpty(r.data.items)      ? `${r.data.items.length} items, all empty (field-mapping bug?)`    : null;  expect(emptyBecause,    `[SILENT EMPTY] ${tool.name} returned no data for its canned args ` +    `(${emptyBecause}). If this emptiness is legitimate, add the tool ` +    `to ALLOWED_EMPTY in tests/live/args.ts with a reason.`,  ).toBeNull();}

The two all-empty rungs weren't in the first version, which checked for dataType=empty and zero-length rows or items and nothing else. USAspending's over-time tool asked the response envelope for the raw API's field names after the SDK had renamed them, so each of 37 periods rendered as an empty row, []. Thirty-seven empty rows is a rows.length of 37, and the first guard passed it. I added the all-empty rungs, and the strengthened guard caught two tools: usa_spending_over_time, and congress_committee_report_text, whose fields were nested inside formats[]. The message says "field-mapping bug?" because in both cases the data arrived and the code read it from the wrong key.

The allow list has 25 tools on it, each under a comment giving the reason. Two weather-alert tools can be empty because the country can be quiet, and FDA drug shortages vary with the world. The Federal Register's public-inspection queue is empty on weekends and holidays. One FEC tool and five Open Payments tools refuse unfiltered queries by design. Fourteen Congress.gov tools are legitimately empty for most subjects (no amendments on an amendment, a committee with no pending nominations, recess weeks, and most amendments have no cosponsors). The last is the FBI use-of-force tool, which is dead upstream. The failure message names the file and asks for a reason, so an exemption without one doesn't get written.

The cost of the guard is the nine stale arguments. Each one was a canned call that had been passing for the wrong reason: the suite was checking that a query about a bill with no votes didn't throw, and it never did. Fixing them meant finding a bill with votes (the infrastructure act), a trial with posted results (the Pfizer COVID vaccine trial) and a real F-150 VIN. For the FEC it meant an indexed committee lookup, since a name search is an unindexed two-minute full scan. A stale argument is a canned call aimed at data that has gone away, and fixing it means swapping in a call aimed at data that exists, so the guard's check stays as written. A canned argument that stops returning data also tells me an agency changed something. The commit that added the guard landed with 1,000 unit tests and 478 live tests green.

The Nightly Run Tests the Live Agencies

The suite runs on cron 17 6 * * *, 06:17 UTC, and never on a pull request, because an agency's outage must not block a merge. Every failure carries a tag. A 4xx is [REPO BUG (4xx)] and fails at once, since a bad parameter or a renamed field is deterministic. A 5xx, a timeout and a network or DNS error are three [UPSTREAM] tags. They're retried twice more for a 5xx or a network error and once more for a timeout, and the retries are budgeted to fit inside the 90 s test timeout (a slow FBI endpoint had been surfacing as a bare vitest timeout). A daily-cap message is [QUOTA EXHAUSTED]. Anything else that throws is [THREW]. Then come two untagged checks, an error-shaped payload and a null result, then [SILENT EMPTY], then [SCHEMA DRIFT].

fig. 2.5.2 · the five checks the nightly runs on every tool call, in order, with the tags each failure carries.
EVERY CALL ANSWERS FIVE QUESTIONS NIGHTLY; "DID IT THROW" IS THE FIRSTONE ROW, ONE CHECK ON EVERY TOOL CALL, IN THE SUITE'S ORDER, NIGHTLY, NEVER ON A PR.ACCENT: THE FAILURE'S TAG.1DID THE CALL THROW?[QUOTA EXHAUSTED][REPO BUG (4XX)][UPSTREAM (5XX)][UPSTREAM (TIMEOUT)][UPSTREAM (NETWORK/DNS)][THREW]2IS THE PAYLOAD SHAPED LIKE AN ERROR?RETURNED AN ERROR PAYLOAD3DID IT RETURN ANYTHING AT ALL?RETURNED NOTHING4IS IT A SILENT EMPTY?[SILENT EMPTY]5DID THE COLUMNS DRIFT?[SCHEMA DRIFT]PASSTHE ANSWER IS IN THE DISK CACHE; THE OFFLINE REPLAY RERUNS IT STRAIGHTAFTER, 455 OF 531 CASES IN ABOUT 80 S WITH ZERO NETWORK1THE MESSAGE NAMES A DAILY QUOTA[QUOTA EXHAUSTED];HTTP 4XX[REPO BUG (4XX)];HTTP 5XX[UPSTREAM (5XX)];A TIMEOUT[UPSTREAM (TIMEOUT)];DNS OR NETWORK[UPSTREAM (NETWORK/DNS)];ANYTHING ELSE[THREW]2THE SUMMARY BEGINS "ERROR", "FAILED", "UNAUTHORIZED", "FORBIDDEN" OR"INVALID API KEY"RETURNED AN ERROR PAYLOAD3NOTHING PARSES OUT OF THE RESPONSERETURNED NOTHING4SKIPPED FOR THE 25 TOOLS ON ALLOWED_EMPTY, EACH LISTED WITH ITS REASON;DATATYPE=EMPTY[SILENT EMPTY];0 ROWS[SILENT EMPTY];0 ITEMS[SILENT EMPTY];N ROWS, ALL EMPTY[SILENT EMPTY];N ITEMS, ALL EMPTY[SILENT EMPTY]5MISSING COLUMNS, AGAINST 102 RECORDED COLUMN SETS;A BASELINE COLUMN ISGONE[SCHEMA DRIFT]

source: us-gov-open-data-mcp tests/live/tools.smoke.test.ts lines 100-105, 152-222; tests/live/args.ts lines 398-422; tests/live/response-shapes.json; .github/workflows/live-smoke.yml lines 4, 9; commit e95252b (repo 796ea9d) · nightlyGauntlet.data.ts

Schema drift compares the response's column set against a baseline recorded in tests/live/response-shapes.json. The file has 308 entries. All 308 record a dataType and 102 record a column set, and the check compares columns only when both the baseline and the response have them, so column drift is checkable for 102 tools. A baseline column that disappears fails the run, and the failure names the missing columns and any new ones, and the fix, once I've looked at what the agency did, is RECORD_SHAPES=1 npm run test:live.

A live run also warms the disk cache, and the offline suite replays from it with fetch stubbed to throw. There are no fixtures in the repo; the recorded responses are the cache entries the nightly run wrote. On the day I added it, 455 of 531 cases replayed end-to-end in about 80 s with zero network, and a case whose cache entry isn't warm skips rather than fails. The nightly runs the replay straight after the live suite, so the replay machinery is tested every night against the responses the agencies just gave.

A broken agency goes into known-upstream-failures.json with a date and a reason. The one entry today is fbi_use_of_force, since 2026-09-01: the FBI's Crime Data Explorer returns 404 for every tested /participation/*/uof* variant while its other endpoints still answer 200. The test runs with it.fails, so the nightly stays green while the outage lasts and turns red when the tool starts working again, which is the signal to delete the entry.

If you're wrapping someone else's data, the suite reduces to seven steps. Give every tool canned arguments that target data known to exist, and fail on empty unless the emptiness is on an allow list with a reason. Treat an array of empty rows as a field-mapping bug, and baseline the column set so drift fails. Run it nightly against the real thing and never gate merges on it, then replay offline from the warmed cache, so the replay itself is proven with zero network. Keep a dated list of known upstream failures, and make recovery turn the build red.

This Is Integration Testing With One Changed Assertion

The strongest objection is that this is integration testing, and the project already had it. The first half is right. The suite that missed the CMS outage was already calling the tools against the live APIs every night. The change is one assertion, that success has a shape other than "no exception", and it moved the nightly count from 2 of the 12 to 8.

The second objection is that allow lists rot. They do, which is why each entry carries a reason and the failure message names the file an exemption has to be written in.

Limits

The guard checks canned arguments only. A tool can be empty for real inputs it was never asked, which is how the CDC state filters stayed broken: the canned calls never set a state, and the audit found the bug by trying both "OH" and "Ohio". Wrong-but-nonempty results are outside it: the BTS quarterly-as-monthly case and the EDGAR whole-corpus case both had the right shape, and both were found by reading output with novel arguments. Schema drift catches some of that class, and no check in the suite catches the rest. Every tool and module count in this essay comes from the audit file. And the catalogue comes from one server and one hardening pass.

The old check asked whether the call threw, and the new one asks whether anything came back. Whether what came back is right still takes a person reading output from new arguments, which is how the BTS and EDGAR rows were found.

Appendix: Reject Inputs That Normalize to an Empty String

The resolver bug in the catalogue is its own class. A fuzzy resolver lowercases the input, strips everything that isn't an ASCII letter or a space, and substring-matches the result against every candidate name. Cyrillic, Chinese and symbol-only input strip to an empty string, and "".includes("") is true for every candidate, so the resolver returns its first or shortest match. World Bank resolved "США" to Cuba, the shortest country name. Census resolved "北京" to Parish village, New York. NHTSA's model resolver did the same with an empty model string. The fix is one line before the match, and I swept it across every resolver. This is the World Bank one, from src/apis/world-bank/sdk.ts, trimmed:

ts
const norm = (s: string) =>  s.toLowerCase().replace(/[^a-z ]/g, " ").replace(/\s+/g, " ").trim();const nq = norm(q);// Non-Latin input normalizes to nothing, and "".includes matches every// country ("США" used to resolve to Cuba, the shortest name). Bail out.if (nq.length < 2) return null;const exact = countries.find(c => norm(c.name) === nq);if (exact) return { code: exact.iso2Code, name: exact.name };const partial = countries.filter(c =>  norm(c.name).includes(nq)  || (nq.length >= 4 && nq.includes(norm(c.name))));if (partial.length) {  partial.sort((a, b) => a.name.length - b.name.length);  return { code: partial[0].iso2Code, name: partial[0].name };}return null;

Census bails the same way when the normalized query is under two characters, with five-digit ZIP codes exempt since those resolve to their own ZCTA codes. Its variable search returns [] for an empty token list instead of every variable. A resolver that can't match returns nothing (null in World Bank, [] in Census), because the caller is usually a model, and a model will take Cuba and keep going.

Andrew Fribush, software and computer engineer in Austin. I build consequential systems where software meets institutions: finance, healthcare, commerce, government data, and the mechanisms that govern AI agents. Taking new engagements: hello@andrewfribush.com.

I write when a system teaches me something. Get new posts by email — no tracking, one-click unsubscribe.