A search engine can tell you a product was recalled. It cannot tell you whether your unit is one of them. HouseCheck can.
Recall notices almost never say "this product is recalled". They say:
Codes P-1950 or 0840962 with a Julian Date between 157 and 184 and a Best By/Sell By date Between July 20 - August 17, 2026
— recall H-1230-2026, Class I, still Ongoing, verbatim from the FDA.
Two cartons of the same eggs, same barcode, sitting in two different fridges: one is under a Class I Salmonella recall and the other is fine. Every recall tool we could find searches product titles and shows you the notice. HouseCheck reads that sentence, compiles it into a decidable predicate, and tests the codes printed on your carton against it.
$ node src/match.ts --upc="0 11110-60902 1" --lot=P-1950 --julian=173 --best_by=2026-08-01
AFFECTED H-1230-2026 [food, Ongoing]
The julian you gave (173) ... every code the notice names matches this unit.
[YES] lot = P-1950 :: "Codes P-1950 or 0840962 with a"
[YES] julian = 173 :: "Julian Date between 157 and 184 and a"
[YES] best_by = 2026-08-01 :: "Best By/Sell By date Between July 20 - August 17, 2026"
$ node src/match.ts --upc="0 11110-60902 1" --lot=P-1950 --julian=200 --best_by=2026-09-01
NOT_AFFECTED H-1230-2026 [food, Ongoing]
The julian you gave (200) is outside the recalled set, and every other code in
the notice was checked.
[YES] lot = P-1950 :: "Codes P-1950 or 0840962 with a"
[NO] julian = 200 :: "Julian Date between 157 and 184 and a"
[NO] best_by = 2026-09-01 :: "Best By/Sell By date Between July 20 - August 17, 2026"
$ node src/match.ts --upc="0 11110-60902 1"
NEED_CODE H-1230-2026 [food, Ongoing]
The notice narrows the recall to specific codes that this unit has not supplied.
ask: What is the Julian date code (the 3 to 5 digit production code) on the
package? (printed on the left or right sides of the carton.)
Same product. Same barcode. Three different answers, because the unit is different. That is the whole idea.
Node 22.18 or newer, nothing else. Node runs the TypeScript directly, so there is no build step.
npm install # one dependency: @modelcontextprotocol/sdk (+ zod)
npm test # 55 assertions over the real frozen notices
npm run serve # http://127.0.0.1:8765Open http://127.0.0.1:8765/ and type 0 11110-60902 1.
There is no network access at runtime and no API key. Every notice is read
from data/recalls.json, a snapshot committed to this repo. Judges can run this
on a plane.
| verdict | means | when |
|---|---|---|
AFFECTED |
this unit is in the recall | every rule the notice names matched |
NOT_AFFECTED |
this unit is outside the recall | every rule was evaluated against a code the owner actually supplied, and at least one definitively excluded the unit |
NEED_CODE |
cannot decide yet, here is the question to ask | any rule is unresolved |
NEED_CODE is a feature. A false clear is the worst thing this software could
do — telling somebody their infant formula is fine when it is under a Class I
recall. So NOT_AFFECTED is the only verdict with a burden of proof, and every
kind of doubt resolves the other way:
- The notice's own text is truncated or the source redacted the codes
(
Lots: 2007xxxxx to 2012xxxxx) → neverNOT_AFFECTED, no matter what codes the owner supplies. - The code text is ambiguous (
06/08/28is both June 8 2028 and 6 August 2028) → every reading is carried as a separate hypothesis and the unit is affected if any of them says so. - The notice names no code at all → the whole product is in scope,
AFFECTED. - A rule cannot be evaluated because the owner has not supplied that code →
NEED_CODEwith the exact question, in the notice's own words including where the code is printed.
Asserted over the whole corpus in tests/, not on a sample:
- No notice returns
NOT_AFFECTEDfor an owner who supplied no codes. - No truncated or redacted notice returns
NOT_AFFECTEDat all. - Of 569,386 codes the notices themselves list, not one is excluded by its own notice.
- Build a unit that a notice's own text says is affected on every key, then swap one key for another code the same notice names: across 94,733 such units none is cleared. That sweep is how three real false clears were found and closed — a lot 400 characters into a list, the second band of "A through B and C through D", and a comma-separated date list whose leading dash read as a range.
data/recalls.json 13,480 frozen notices (openFDA x3 + CPSC, fetched 2026-09-07)
data/manifest.json fetch date + every request URL, so the snapshot is reproducible
data/fetch.ts re-runs the ingestion
data/CODE_INFO_TAXONOMY.md the 25 shapes of code_info text, measured over the corpus
src/predicates.ts the crown jewel — code_info free text -> decidable predicate
src/match.ts resolve a product to candidate notices (SQLite FTS5 + barcode)
src/card.ts the result card, one renderer for the CLI and the web client
src/mcp-server.ts MCP over Streamable HTTP, spec 2025-11-25 + static web client
web/index.html simulated Alexa+ client: conversational UI, talks MCP over HTTP
tests/ 55 assertions, plain `node --test`, no framework
Not 25 regexes. One pipeline:
scan code_info for field labels ("Lot:", "Best By", "Julian Date", "UDI-DI")
-> slice the value segments between them
-> classify each value's shape: universal | cutoff | range | prefix | list
-> compile a Clause with test(value) -> YES | NO | UNKNOWN
Clauses sharing a key are ORed (a notice listing "lot A made 27 Apr" and "lot B
made 28 Apr" recalls either). Distinct keys are ANDed (a model gate and a lot
list means both must hold). Twelve key kinds: lot, best_by, expiry, mfg_date, julian, date_code, model, serial, upc, udi, software, plant.
Ambiguity is never resolved by a coin flip; it becomes a disjunction of
hypotheses. Julian Dates 3355 to 1536 (mfg. 12/01/2025 to 06/02/2026) is
DDDY, not YYDDD — day 335 of 2025 to day 153 of 2026 — and a naive numeric
compare inverts the range. So every valid decoding is kept, the unit is
AFFECTED if any hypothesis says so, and NOT_AFFECTED only if all of them do.
95,475 clauses over 13,480 notices, parsed and evaluated in about 1.1 seconds.
What the corpus actually looks like, measured, not estimated:
| notices | share | |
|---|---|---|
| unit-decidable — two owners can get two different verdicts | 9,736 | 72.2% |
| whole product — every unit in scope, no code needed | 3,479 | 25.8% |
| undecidable — source truncated or redacted the codes | 265 | 2.0% |
That 72.2% is the gap this project closes.
POST http://127.0.0.1:8765/mcp spec 2025-11-25, stateless, JSON responses
Five tools, each calling the engine above — nothing in the server re-implements matching or restates a verdict:
| tool | for |
|---|---|
check_recall |
the main one. product + whatever codes are on the package → a verdict per notice, with the next question when it is NEED_CODE |
search_product |
which notices could be about this product, no verdict — the thing every other tool stops at |
explain_verdict |
one notice, clause by clause: each rule, the owner's value, in or out |
get_remedy |
the agency's own instructions, verbatim, plus a source link |
list_recent_recalls |
recent notices, filterable by category, status and hazard class |
Tool and field descriptions are written for an agent that has never seen a recall
notice — it has to know from the schema alone that a "Julian date code" is the 3
to 5 digit production code, and that NEED_CODE means ask this question and
call again.
curl -s -X POST http://127.0.0.1:8765/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"check_recall",
"arguments":{"upc":"0 11110-60902 1","lot":"P-1950","julian":"173","best_by":"2026-08-01"}}}'Both Accept values are required by the transport. Point any MCP client at
http://127.0.0.1:8765/mcp with transport http.
A single HTML file. It speaks MCP over Streamable HTTP with fetch, holds a
short conversation, and renders the result card the server sends back. Type a
product or a barcode; when the server answers NEED_CODE, the next thing you
type fills the code it asked for.
No frameworks, no build, no assets. It reads as a calm assistant rather than a debug console because the point being demonstrated — one product, two codes, two verdicts — has to survive a two-minute video.
One renderer, used by the web client and by a standalone-file CLI:
node src/card.ts "kroger grade a eggs" --lot=P-1950 --julian=173 --best_by=2026-08-01 --out=card.htmlEvery card carries the verdict, the exact code_info text the engine reasoned
over, a row per rule showing the owner's value and whether it fell inside or
outside, the recall reason verbatim, the remedy verbatim where the source has
one, and a link to the primary source notice. Nothing on a card is paraphrased,
and no safety or medical advice is added to what the agency wrote.
Fetched 2026-09-07. Keyless, public, no account:
| source | notices | report dates |
|---|---|---|
api.fda.gov/food/enforcement.json |
4,000 | 2023-12-20 → 2026-08-19 |
api.fda.gov/drug/enforcement.json |
3,000 | 2023-02-22 → 2026-08-19 |
api.fda.gov/device/enforcement.json |
4,000 | 2025-04-23 → 2026-08-19 |
saferproducts.gov/RestWebServices/Recall |
2,480 | 2019-01-30 → 2026-09-03 |
data/manifest.json records every request URL. node data/fetch.ts re-runs the
whole ingestion; 12 requests total, against a limit of 240/min and 1,000/day.
Two ingestion decisions worth knowing:
code_infois capped at 20,000 characters. 243 device notices inline every affected serial number — the largest is 8.8 MB of comma-separated lots. Each capped record keepscode_info_truncatedwith the true length, and the matcher routes those toNEED_CODErather than reason over a partial list and risk a false clear.- CPSC has no
code_infofield; its qualifiers live in the recall prose, soDescriptionis mapped in and parsed identically.
npm test # or: node --test
# tests 55 pass 55 fail 0Every assertion names the recall_number it is about and reads that notice out
of the frozen snapshot — no fixtures, no invented code text. Plain node --test;
no jest, no vitest, no config.
Note: node --test tests/ fails — Node treats the directory as a module
path. Use bare node --test or node --test tests/*.test.ts.
Listed because a safety tool that hides its edges is worse than one that does
not have them. Every one of these degrades to NEED_CODE or over-flags. None
of them can produce a false clear.
- GS1 AI decomposition.
(17)280501(10)0001441871matches as a UDI string, but the embedded(17)expiry and(10)lot are not pulled out as independent keys. 420 notices lose a free second key. - Interleaved drug lot/expiry binding.
Lot #s: 22142448, Exp 5/2024; 22143845, Exp 7/2024binds each expiry to the run of lots before it. We parse lots and expiries as separate ORed groups, so a pairing that does not exist in the source still returnsAFFECTED. - Timestamp windows.
Plant Code: PLT19-145 Timestamp: 17:51 to 21:23— only part of one shift is recalled; we flag the whole day. - Serial layouts that differ from the notice's own return
UNKNOWNrather than guess at a comparison between differently-shaped codes. - Declared date-code layouts are inferred, not consumed. When a notice
states
YYDDD, we re-derive it instead of reading the declaration. - CPSC size ranges ("sizes 2T through 5T") emit no clause, so the whole product is flagged.
- Slash-joined headers —
REF/UDI-DI/Serial/Lot: PRT-00853/003897.../155391Pparse as one list rather than four positional fields.
And the standing limit that is not a bug: the snapshot is a fixed window. "No notice matches" is not a clearance, and the tool says so in those words.
Required by the hackathon, and the honest version is in
FRICTION_LOG.md — written during the build, not
reconstructed afterwards, with each entry naming the file or line the friction
changed. It covers all six tools used, each with what it was for, what worked,
what needs work, onboarding, and whether we would use it again.
The short version:
@modelcontextprotocol/sdk 1.30.0 — Streamable HTTP. Would use again.
LATEST_PROTOCOL_VERSION is already 2025-11-25, so the required revision needs
no configuration, and registerTool generates the tools/list JSON Schema from
the Zod shape including every .describe() string. Three things cost time:
installing it broke a working project, because Node 22 runs .ts natively and
HouseCheck had no package.json until then — npm init -y writes
"type": "commonjs" and 50 passing tests became 50 failures pointing at files we
had not touched. Second, the transport rejects any request whose Accept header
is not both application/json and text/event-stream, even with
enableJsonResponse: true; the natural first curl fails. Third, returning
structuredContent alongside text works when a tool declares no outputSchema —
which is what let one tool call serve both a spoken summary and a rendered card —
but we only know it is supported by reading the compiled dist. A page called
"what your HTTP client must send" would have saved all of it.
openFDA enforcement API. Would use again; it is the reason this project
exists. code_info is the richest free-text field in any public safety dataset.
But it returns HTTP 404 for an exhausted page rather than an empty result
set, there is no remedy field at all on the enforcement endpoints — so a
safety tool literally cannot tell anyone what to do without linking out — and the
only citable URL per notice is a JSON API query, not a page a person can
read.
CPSC saferproducts. Complementary rather than equivalent: 2,479 of 2,480
records carry a remedy, which is exactly what openFDA lacks, but there is no
code_info field, so the qualifiers have to be mined out of prose.
node:sqlite + FTS5, Node 22 native TypeScript, node:test. All three would
be used again, and together they are why this repo has one dependency and no
build step. spellfix1 is not compiled into Node's SQLite (checked, not
assumed), so fuzzy matching is a prefix-query fallback.
MIT — see LICENSE.
Recall data is public-domain output of the U.S. FDA and CPSC. Every verdict links its primary source notice, and agency text is reproduced verbatim and unedited.
HouseCheck is not affiliated with, endorsed by, or connected to Amazon, the FDA or the CPSC. It is an information tool, not safety or medical advice: read the primary source notice and follow the recalling firm's instructions.