Skip to content

An MCP server over a real open dataset

  • Case study 1
  • 180 min
  • Optional · 1 case study needed for the certificate
Case study 1 of 2 · MCP: Connect AI Applications to Tools and DataAn MCP server over a real open dataset

What you will be able to do

Build a read-only MCP server over real public data: the food recall reports of the U.S. Food and Drug Administration (FDA). You design the capabilities from people’s needs, check the data, and bound and measure every answer. Then you test the contract, inspect the server, connect the course’s host with a mock model, and compare three designs under one measure.

Case studyPortfolio piece

Time
About 3 hours
Runs in
VS Code, Python 3.12+
Skills used
  • Capability catalog
  • Data checks with SHA-256
  • Bounded tool results
  • Stable resource URIs
  • Contract tests
  • MCP Inspector, modern era
  • Mock-model host
  • A fair comparison

Unzip the starter, open the folder in VS Code, and follow the steps below. They start with a new virtual environment.

The starter has the data download and its checks, the business service, the course’s host and the measuring scripts. You add the capability catalog, the contracts, the server and its tests. The finished project is the result, for when you are stuck. The code is under the MIT licence. The people in the story are made up; the recalls are real public records.

Before you startWhat to know and to have readyAbout 180 minutes

What you need to know

This case study uses the whole course, on data that is not Larkfield’s. It leans most on Tools, Resources, Prompts, Inspect behaviour, Discovery and execution and Results and failures. The contract test follows Contract and compatibility tests, and the bounds follow Resource limits and observability.

What you need on your computer

  • Python 3.12 or newer and VS Code. Node.js 22.19 or newer, for Inspector only.
  • About 120 MB of free disk space: the packages, the data (5.9 MB to download) and its database (30 MB).
  • No account, no key and no money. A real model is optional.

Tested on macOS (Apple M4, 16 GB, Python 3.12.13, Node.js 24.18.0). The Windows and Linux commands are written but not tested. This case study uses MCP specification revision 2026-07-28 with the official MCP Python SDK 2.3.0.

Marisol Reyes leads food safety at Cedar Lane, a chain of 14 grocery stores. Dev Okoro builds its software. Both are made up.

Every week the help desk gets the same questions. “I bought frozen oysters. Are they recalled?” “The news talked about recall H-1137-2026. What is it?” Agents search the FDA website by hand, and the answers differ from one agent to the next.

Cedar Lane already uses two AI applications: the help desk’s assistant and a staff assistant in the store app. A third, from a delivery partner, is coming. Marisol does not want three integrations with the same public data. Dev proposes one MCP server that all of them can use.

Marisol sets four rules:

  • Read-only. The assistant looks things up. It changes nothing.
  • Name the source. Every answer gives the FDA recall number, so that a person can check it.
  • Never “safe”. “No recall found” means “not in this data”. It never means “this product is safe”.
  • No floods. No answer may be so large that the assistant cannot read it.

The objective. One MCP server over FDA’s public food recall reports. On a fixed set of 22 questions, the course’s host with a mock model names the right recall every time. Every tool answer fits the host’s limit of 4,000 characters. A contract test catches any change that clients can see.

At the top, a chain from left to right. The openFDA food enforcement file: 29,471 records, 5.9 MB, public domain (CC0 1.0). An arrow labelled download leads to scripts/get_data.py, in amber: it checks SHA-256 fa59ccc9… and leaves out 47 records whose recall number is not valid. An arrow labelled build leads to data/recalls.db: SQLite and a full-text index, 29,424 recalls. An arrow labelled reads leads to store.py, the business service, with no MCP. From store.py an arrow leads down to the MCP server, food-recalls (stdio, revision 2026-07-28, SDK 2.3.0). Inside it, three boxes. Tools, the model chooses: search_recalls, at most 5 short summaries, newest first; get_recall, one recall, long texts cut. Resources, the application chooses: recall://fda-food/{recall_number}; the list holds the newest ongoing Class I recalls, at most 50. Prompt, the person chooses: customer_answer(recall_number), name the source, no medical advice. On the left, two clients connect to the server with arrows labelled MCP: the course's host (model: the mock, or a real one; runs allow-listed tools only; cuts any result over 4,000 characters) and MCP Inspector 2.10.1 with --protocol-era modern and no model.
The finished project. The data is checked before the server reads it. The server offers two bounded tools, a resource template and a prompt. The course's host and Inspector are its clients.
Text description of the diagram

The data flows from the openFDA file through scripts/get_data.py into a SQLite database of 29,424 recalls. The script checks the SHA-256 checksum and leaves out 47 records with a bad recall number. store.py reads the database, without MCP. The MCP server food-recalls offers two read-only tools, search_recalls and get_recall. It also offers a resource template, recall://fda-food/{recall_number}, with a list of the 50 newest ongoing Class I recalls, and a prompt, customer_answer. The course’s host and MCP Inspector connect to it over stdio.

Part What you do Evidence
1 Check the data the checksum, 47 bad IDs, the sizes
2 Write the capability catalog docs/capability-catalog.md
3 Build the server with bounded results recalls_mcp/contracts.py, server.py
4 Test the contract and measure the bounds 32 tests, contract/capabilities.json
5 Inspect it in the modern era Inspector’s answers
6 Use the host, and break it on purpose an answer that names its source
7 Compare three designs under one measure 22 questions
8 Ask new questions, with a real model as an option the runs

The data is FDA’s food recall enforcement reports, from openFDA. One record is one recalled product: its recall number (for example H-1393-2026), the firm, the product, the reason, the class, the status and the dates. Class I is the most serious class: a reasonable chance of serious harm. One recall event can cover many products, so many records can share an event number.

openFDA publishes a new file every week under the same name, so the original link cannot have a fixed checksum. The course keeps the exact file it used, the export of 2026-10-06 (5.9 MB), in its labs repository; CC0 allows that. scripts/get_data.py downloads that copy and checks it. The project’s DATASET.md is the dataset card: the source, the licence, the checksum, the changes and the limits. The course also looked at two other candidates. A consumer product recall service states no licence for its data, so the course did not use it.

The steps use VS Code and its terminal. Every command runs in the project folder.

  1. Download the starter project from the box at the top of this page. Unzip it into your projects folder, for example ~/projects. You get the folder food-recalls.

  2. In VS Code, choose File > Open Folder, and open food-recalls. Then open a terminal: Terminal > New Terminal.

  3. Make and activate a virtual environment, install the packages, and get the data:

    Terminal window
    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install -r requirements-dev.txt
    python -m scripts.get_data
    Output (end)
    Checked food-enforcement-2026-10-06.json.zip: SHA-256 fa59ccc9642b... is correct
    Wrote data/recalls.db: 29,424 recalls (openFDA export of 2026-10-06)
    Left out 47 records whose recall number is not in FDA's form: data/excluded.jsonl

    The prompt starts with (.venv). The packages are the course’s versions: mcp 2.3.0, pytest 9.1.1 and ruff 0.17.0.

  4. Run the tests of the starter:

    Terminal window
    python -m pytest -q
    Output
    ......... [100%]
    9 passed in 0.04s

The nine tests check the business service alone: the checksum, the counts, the search and one recall. There is no MCP yet.

Part 1: check the data before you serve it

Section titled “Part 1: check the data before you serve it”

A checksum is a short fingerprint of a file. If one byte changes, the SHA-256 fingerprint changes completely. scripts/get_data.py compares the fingerprint of the download with the one in data/SHA256SUMS. When they differ, it deletes the file and stops. So the server never reads a changed or broken download, and your numbers match this page.

A resource URI such as recall://fda-food/H-1393-2026 is only stable if every recall has one valid, unique ID. FDA’s recall numbers have one form: a letter, a dash, four digits, a dash, four digits. The check found 47 of 29,471 records (0.16%) that do not fit. Some examples:

'' (empty; reported 2026-09-30)
'F1462-2014' 'F-1772' 'F-0455-201' 'Ff-2095-201' 'F-1855.2013'

Dev had three choices: repair the numbers, invent IDs, or leave the records out. A repaired number can point to another real recall, and an invented ID means nothing to FDA. So the build leaves the 47 records out and lists them in data/excluded.jsonl. This has a cost: one of them is a recent recall with an empty number. It is one more reason for Marisol’s rule that “not found” never means “safe”.

The server’s job is to answer questions within bounds. So the useful facts are sizes: how long one record is, and how many records one question matches.

  • One record. The median is 1,100 characters of JSON. The largest is 44,645 characters, and 41,747 of them are lot codes.
  • One question. “american ginseng” matches 3 recalls, “infant formula” 74, and “listeria” 7,487.
  • One event. The largest recall event has 409 recalls.

Most records are small, but a few are huge, and some questions match thousands. A design that is fine for the median can fail on the largest answer.

Start from people’s needs, not from the data’s 25 columns, as in Tools. Open docs/capability-catalog.md. The needs are there; fill in the catalog. Here is Dev’s:

Need Capability Who chooses Bounds
Is this product recalled? tool search_recalls(query, status, classification, limit) the model query 3–100 characters; at most 5 results, newest first; product and reason cut to 200 characters; the number of matches
What is recall X about? tool get_recall(recall_number) the model FDA’s form, ^[A-Z]-\d{4}-\d{4}$; one record; each long text cut
Read or cite the whole report resource template recall://fda-food/{recall_number} the application or the person one report as Markdown; only the lot codes cut, at 3,000 characters
The serious recalls that are still open resources/list the application the newest ongoing Class I recalls, at most 50
One way to answer a customer prompt customer_answer(recall_number) the person the number checked; the source named; no medical advice

The rejected ideas are as useful as the catalog. list_all_recalls() would return 29,424 records. A tool that runs SQL would have no bounds and no contract. A list entry for every recall would give 29,424 entries. The template describes them all, and the list holds the 50 that matter this week.

The data is public, so the server has no sign-in and no organizations: every caller sees the same data. A server with private data needs the checks of Identity and permission.

The problem: a thin wrapper floods the assistant

Section titled “The problem: a thin wrapper floods the assistant”

The quickest server is a thin wrapper: the tool runs the search and returns every match, whole. Dev builds it first, as the baseline. Then he asks it a question from Part 1: “Is infant formula under a recall?” The server sends 74 whole records, 94,611 characters. The course’s host cuts any result after 4,000 characters, as in Results and failures. The cut text is no longer valid JSON. The model cannot read it, so there is no answer.

The idea: summaries first, details on request

Section titled “The idea: summaries first, details on request”

Think of a library catalogue. A catalogue card is short: the title, the author and the shelf mark. You read a few cards, choose one, and then fetch that one book. Nobody puts every matching book on the counter.

The same idea works for a tool result. search_recalls returns short summaries: at most 5, newest first, each with its recall number and its URI. It also says how many recalls match, so the model knows that there are more. Then get_recall returns one whole record, with each long text cut to a fixed length. So every answer has a known maximum size, whatever the question.

One question, three designs: Is infant formula under a recall? 74 recalls match. Three columns. Everything, every match whole: the server sends 74 whole records, 94,611 characters; the host cuts at 4,000 characters and the JSON is broken; the model has no answer; right answers on 22 questions: 10 of 22. Full, the 5 newest whole: the server sends 5 whole records, 6,385 characters; the host cuts at 4,000 characters and the JSON is broken; the model has no answer; 10 of 22. Summary, 5 summaries then one recall: the server sends search_recalls 2,668 characters, then get_recall H-1137-2026 1,407 characters; each result fits under 4,000 characters; the model names H-1137-2026, the newest match; 22 of 22.
The same question, three result designs, the same host. Only the summary design keeps every result under the host's limit.
Text description of the diagram

For “Is infant formula under a recall?” (74 matches):

Design The server sends The host The model Right, of 22 questions
everything: every match, whole 74 records, 94,611 characters cuts at 4,000: broken JSON no answer 10
full: the 5 newest, whole 5 records, 6,385 characters cuts at 4,000: broken JSON no answer 10
summary: 5 summaries, then one recall 2,668, then 1,407 characters both fit names H-1137-2026 22
  1. Add recalls_mcp/contracts.py, as in Tools: the bounds, the input types and the output models. The bounds are named constants, so that a test can read them:

    recalls_mcp/contracts.py (part)
    MAX_QUERY_CHARS = 100
    MAX_RESULTS = 5
    DEFAULT_RESULTS = 5
    SUMMARY_CHARS = 200 # product and reason in a search result
    DETAIL_CHARS = { # each text field of get_recall
    "product_description": 1000,
    "reason_for_recall": 500,
    "product_quantity": 200,
    "code_info": 1000,
    "distribution_pattern": 500,
    }
    RECALL_URI_TEMPLATE = "recall://fda-food/{recall_number}"
    RecallNumber = Annotated[
    str, Field(pattern=RECALL_NUMBER, description="An FDA recall number, for example F-0123-2025.")
    ]

    The finished file also has the RecallSummary, SearchResult and Recall models, and the text of the prompt.

  2. Add recalls_mcp/server.py. Each tool is a function with a one-line docstring, read-only annotations and typed inputs, as in Local transport:

    recalls_mcp/server.py (part)
    @mcp.tool(annotations=READ_ONLY)
    def search_recalls(
    query: contracts.Query,
    status: contracts.Status = "any",
    classification: contracts.Classification = "any",
    limit: contracts.Limit = contracts.DEFAULT_RESULTS,
    ) -> contracts.SearchResult:
    """Search FDA food recall enforcement reports, newest first. Returns at most 5 short summaries and the number of matches. Read one with get_recall."""
    started = time.perf_counter()
    try:
    total, rows = store.search(
    query,
    None if status == "any" else status,
    None if classification == "any" else classification,
    limit,
    )
    except ValueError as e:
    raise ToolError(f"invalid_query: {e}") from None
    log.event("tool_call", tool="search_recalls", matches=total, returned=len(rows), ms=logs.ms_since(started))
    return contracts.SearchResult(query=query, total_matches=total, results=[summary(r) for r in rows])

    get_recall raises ToolError("not_found: …") for a number that is not in the data. The resource raises ResourceNotFoundError, which the SDK sends as −32602, as in Resources. The finished server also has a switch, RECALLS_DESIGN, for the two alternatives of Parts 6 and 7. Parts 6 and 7 need it, so take that part from the finished server.py. Use the switch only for the comparison.

  3. Add recalls_mcp/__main__.py, which runs the server on stdio, and recalls_mcp/logs.py, which writes one JSON line per call to stderr. On stdio, stdout belongs to the protocol.

  4. If you are stuck, copy the four files from the finished project. Then ask the server one question through the course’s host:

    Terminal window
    python -m host "Is american ginseng under a recall?"
    Output (the server's log lines on stderr left out)
    [host] discovered 2 tools: search_recalls, get_recall {"protocol": "2026-07-28", "server": "food-recalls"}
    [model] wants search_recalls {"arguments": {"query": "american ginseng"}}
    [host] read-only tool on the allow-list: run it {"decision": "auto"}
    [server] result: 1434 characters {"ms": 21.9, "chars": 1434, "clipped": false}
    [model] wants get_recall {"arguments": {"recall_number": "H-1393-2026"}}
    [host] read-only tool on the allow-list: run it {"decision": "auto"}
    [server] result: 862 characters {"ms": 3.9, "chars": 862, "clipped": false}
    [model] final answer
    Recall H-1393-2026 (Class III, Ongoing, reported 2026-09-30): Kwok Shing, Inc., American Ginsengs. PRODUCT OF WISCONSIN. NET WT: 4 oz. NET WT. 8 oz. Reason: Potential contamination with pesticides. Source: recall://fda-food/H-1393-2026. 3 recalls match; this is the newest.

What this tells us. The host is the course’s host from Client integration, with four changes. It starts this server, it has its own allow-list and system prompt, and its mock model has rules for this server. The mock searches with the question’s words, without question words such as “is” and “recall”. Then it reads the newest match with get_recall and names it. The two tool results were 1,434 and 862 characters.

The host starts the server with an allow-list of settings, as in Credential boundaries. A model key in your shell never reaches the server.

Part 4: test the contract and measure the bounds

Section titled “Part 4: test the contract and measure the bounds”

The users of the server are other applications. So its contract is what they see: names, descriptions, schemas, the resource template and the prompt. Copy scripts/contract.py from the finished project. It asks the server for its contract and compares it with contract/capabilities.json, as in Contract and compatibility tests. Write the file once, then check it:

Terminal window
python -m scripts.contract --write
python -m scripts.contract
Output
Wrote capabilities.json: 2 tools, 1 resource template, 1 prompt
The server matches contract/capabilities.json.

Then copy the test files of the finished project: tests/conftest.py, which replaces the starter’s, and the four new files test_server.py, test_contract.py, test_stdio.py and test_host.py. Run all the tests:

Terminal window
python -m pytest -q
Output
................................ [100%]
32 passed in 1.05s

The 23 new tests check what this case study promised:

  • Bounds. A limit of 6, a query of 2 characters and a query of 101 characters are tool errors. The 20 largest records, and the largest search answer of the 20 product questions, fit the host’s 4,000 characters.
  • Errors. H1393 breaks the pattern. F-9999-2001 is not_found. A missing resource is −32602, with data.uri.
  • Safety. Every tool is read-only. Query text cannot use the search engine’s own syntax (OR, *, NEAR), because the server quotes each word.
  • Transport. The SDK’s client starts python -m recalls_mcp as a child process, in revision 2026-07-28 and in the legacy initialize era.
  • The contract. The URI template never changes, and every input has a bound.

How large is the largest answer? scripts/sizes.py reads every one of the 29,424 recalls through an MCP client: once with get_recall, and once as a resource.

Terminal window
python -m scripts.sizes
Output
answer count median p95 p99 max (characters)
openFDA record (no bound) 29,471 1,100 1,636 2,451 44,645
get_recall result 29,424 1,098 1,627 2,105 3,590
recall resource text 29,424 831 1,369 2,184 7,713
search_recalls, limit 5 20 2,423 2,711 2,820 2,820
resources/list: 50 recalls (bound 50)
  • no bound
  • bounded by the server
The largest answer of each kind, over all recalls (the search: over the 20 product questions). The bounds hardly change the median record; they cut the largest.
Text description of the chart

The largest openFDA record has 44,645 characters. The largest recall resource text has 7,713, the largest get_recall result 3,590, and the largest search answer with limit 5 2,820. The medians are 1,100 characters for a record and 1,098 for a get_recall result.

What this tells us. The bounds do not make typical answers shorter: the median get_recall result is as long as the median record. They cut the long tail, from 44,645 characters to 3,590. That is the purpose of a bound: a known maximum. The resource text can be longer, up to 7,713 characters, because the application chooses to read it whole, as in Resources.

The tests use the SDK’s own client. Now look at the server the way another client sees it, with MCP Inspector, as in Inspect behaviour. By default, Inspector 2.10.1 uses the legacy initialize handshake. Add --protocol-era modern for revision 2026-07-28. Write the server command first, then --, then Inspector’s options:

Terminal window
npx @modelcontextprotocol/inspector@2.10.1 --cli python -m recalls_mcp -- --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector@2.10.1 --cli python -m recalls_mcp -- --protocol-era modern --method tools/call --tool-name search_recalls --tool-arg query=listeria --tool-arg limit=11
npx @modelcontextprotocol/inspector@2.10.1 --cli python -m recalls_mcp -- --protocol-era modern --method resources/read --uri recall://fda-food/F-9999-2001
Output of the second command (part), exit code 5
"text": "Error executing tool search_recalls: 1 validation error for search_recallsArguments\nlimit\n Input should be less than or equal to 5 [type=less_than_equal, input_value=11, input_type=int]…"
"isError": true
Output of the third command, exit code 1
{"error":{"code":"error","message":"Resource not found: recall://fda-food/F-9999-2001"}}

The course ran twelve such commands, eleven of them in the modern era. Each answer matched the tests: two tools with their bounds, the URI template, 50 listed resources, the prompt, a not_found tool error and the refused limit. A recorder between Inspector and the server showed server/discover, then requests with _meta for revision 2026-07-28. For the missing resource, the CLI prints “error”, not −32602; the tests check the code.

Part 6: use the host, and break it on purpose

Section titled “Part 6: use the host, and break it on purpose”

Try two more questions: one with no match, and one by recall number.

Terminal window
python -m host "Is there a recall of dragon fruit gummies?"
python -m host "What is recall H-1393-2026 about?"
Output (the final answers)
I found no FDA food recall that matches "dragon fruit gummies" in the data.
Recall H-1393-2026 (Class III, Ongoing, reported 2026-09-30): Kwok Shing, Inc., American Ginsengs. PRODUCT OF WISCONSIN. NET WT: 4 oz. NET WT. 8 oz. Reason: Potential contamination with pesticides. Source: recall://fda-food/H-1393-2026.

The first answer says “in the data”. It does not say “safe”. The second answer needed one call: the mock saw a recall number and went straight to get_recall.

Now break it on purpose. Start the server with the “full” design, which returns the 5 newest whole records:

Terminal window
RECALLS_DESIGN=full python -m host "Is there a listeria recall?"
Output (the server's log lines left out)
[host] discovered 2 tools: search_recalls, get_recall {"protocol": "2026-07-28", "server": "food-recalls"}
[model] wants search_recalls {"arguments": {"query": "listeria"}}
[host] read-only tool on the allow-list: run it {"decision": "auto"}
[server] result: 4816 characters {"ms": 25.7, "chars": 4816, "clipped": true}
[model] final answer
I could not read the result of search_recalls: result cut to 4000 characters by the host.

The host passed RECALLS_DESIGN to the server because it is on the allow-list. Five whole records were 4,816 characters, so the host cut them, and the mock could not read the cut JSON. A real model can guess from cut text, which is worse: the guess looks like an answer.

Part 7: a fair comparison under one measure

Section titled “Part 7: a fair comparison under one measure”

Is the summary design really better, or did Dev choose easy questions? Compare the three designs fairly: the same 22 questions, the same host, the same mock model and the same data. Only the design changes.

The questions are in questions/eval.jsonl: 15 about one product, 3 broad ones (“romaine lettuce”, “peanut”, “listeria”), 2 with no match, and 2 by recall number. Each question records the expected recall event. For each product question, the course checked by hand that the newest match is the product asked about. So the test measures the shape of the result, not the search ranking.

The measure. An answer is right if it names a recall of the expected event, or says “no recall” when none matches. The script also counts the characters of tool results that the model read.

Terminal window
python -m scripts.compare
Output
22 questions, mock model, host limit 4,000 characters per result
design right cut model read (median / max) server sent (median / max)
everything 10/22 12 4,256 / 4,456 4,278 / 8,345,314
full 10/22 12 4,256 / 4,456 4,278 / 8,278
summary 22/22 0 2,868 / 4,464 3,096 / 4,720
  • host limit 4,000 characters
  • no host limit
Right answers on the 22 questions, with the mock model (all: the everything design). With the host's limit, only the summary design answers every question. Without a limit, all three do, at a very different size.
Text description of the chart

With the host’s limit of 4,000 characters, the “everything” design was right 10 times, “full” 10 times and “summary” 22 times. Without a limit, each of the three designs was right 22 times.

What this tells us. Three findings.

  • Both whole-record designs failed on the same 12 questions. Every failure was a cut result. “bee pollen” failed by 3 characters: its 4 whole records were 4,003 characters. A design that works on most questions is not bounded.
  • The summary design was right 22 times and read the least: a median of 2,868 characters, in two tool calls. Its largest pair of answers was 4,720 characters in all, and each answer alone fit the limit.
  • Change one thing: remove the host’s limit, with python -m scripts.compare --host-limit 10000000. Then all three designs are right 22 times. But for “listeria”, the “everything” design hands the model 8,345,360 characters, far more than a model can read in one request. Removing a limit does not make a design good. It moves the failure to the model.

Is the comparison fair? The questions, the host, the model and the data were the same. The mock model has no chance in it: every run gives the same answers. The course wrote the questions, so they test the shape of the result, not how well the search ranks. And 22 questions are few: they show a failure, not a rate.

Use the summary design. It gives at most 5 short summaries with the number of matches, then one bounded record on request. The resource gives the whole report. Keep the bounds as named constants, with a test for the largest case. Keep the contract file, and change it only after a reviewed diff. Do not raise the host’s limit to rescue a design that sends too much.

The comparison used fixed questions. Now ask questions that were not in it. With the mock model:

Terminal window
python -m host "Are half shell oysters recalled?"
python -m host "Is kombucha under a recall?"
Output (the final answers)
Recall H-0482-2026 (Class II, Terminated, reported 2026-02-11): Khee Trading, Inc., Product is Frozen Half Shell Oysters in 15-16 pound blocks. Packaged in generic brown cardboard boxes. Side panel … Reason: Imported oysters potentially contaminated with Norovirus. Source: recall://fda-food/H-0482-2026. 13 recalls match; this is the newest.
Recall F-1461-2023 (Class II, Terminated, reported 2023-09-06): C & S Wholesale Grocers Inc., The following items sold at Stop & Shop stores located in the New York Metro Region: Natures Promise Lemonade Natures … Reason: Perishable products were not held at an appropriate temperature. Source: recall://fda-food/F-1461-2023. 13 recalls match; this is the newest.

The oysters answer is right. The kombucha answer shows a limit of word search. The newest match is a list of many products, and kombucha is one of them. The answer is true, but an agent must read the record before telling a customer. The mock model always takes the newest match and cannot judge this. A real model can read the five summaries and choose.

The host also works with any OpenAI-compatible model, as in Client integration. The course ran four new questions with an Azure deployment of a small model, chat-small. You do not need to do this: the runs are summarized in the project’s docs/results.md.

The first run found a real defect. The first version allowed a limit of up to 10, and the model asked for 10 infant formula recalls. The answer was 5,531 characters, and the host cut it. The model still answered from the cut text, which was luck. Dev lowered the maximum to 5, measured again (the largest search answer is now 2,820 characters), and added a test. The contract test then showed the change, in the description, maximum and maxItems, as a diff to review.

After the fix, the four runs used 10 model calls and cost US$0.0013 in all. Here is one of them, checked against the data:

chat-small, after the fix (bold marks of the answer removed)
Q: Has any infant formula been recalled recently? Give me the newest one.
[model] wants search_recalls {"query": "infant formula", "limit": 5} → 2,668 characters
[model] wants get_recall {"recall_number": "H-1137-2026"} → 1,407 characters
A: Yes. The newest infant formula recall in the FDA results is Nara Organics Whole Milk Organic Infant
Formula, recall H-1137-2026, reported July 8, 2026. It’s an ongoing Class I recall because the product
may be contaminated with Clostridium botulinum. …

For the oyster question, the model asked for four recalls in one turn, and said correctly that all four were terminated. One model on one day proves nothing about the next one. The tests and the bounds do not depend on it.

Common mistake

The host cuts a result that the tests said was fine

Symptom: a real model gets a result with the note “result cut to 4000 characters by the host”, although python -m pytest passes.

Cause: the tests checked the default limit of 5, but the schema allowed 10. A model can ask for the maximum, and then the answer can be over the limit.

Fix: test the largest answer that the schema allows, not the default. Measure it on the real data (python -m scripts.sizes), and lower the bound until the largest case fits.

Common mistake

The tests stop before they start

Symptom: python -m pytest prints data/recalls.db is missing: run python -m scripts.get_data first.

Cause: the database is not in the zip. The tests use the real data.

Fix: run python -m scripts.get_data. If it reports a checksum mismatch, the download was changed or broken: run it again. Do not edit SHA256SUMS.

  • Bound the worst case, not the typical one. Most records are about 1,100 characters, and most designs work on most questions. The failures came from the long tail: one record of 44,645 characters, and a query with 7,487 matches.
  • Check the data before you give it addresses. Stable URIs need valid, unique IDs. 47 records had none, and the honest choice was to leave them out and say so.
  • A real model finds what the mock cannot. The mock always asked for 5 results. The real model asked for 10, and found a bound that was too large.
  • Limits of this study. One dataset, one laptop, 22 questions written by the course, and a mock model. The search matches words, not products. The data stops at reports of 2026-09-30. Windows and Linux were not tested.
  • “Not found” is not “safe”. The data has only enforcement reports. They arrive after FDA classifies a recall, and 47 records are left out. Every answer says “in the data”, and the agent sends the customer to the FDA notice.
  • No medical advice. The server’s instructions and the customer_answer prompt tell the model not to give it. FDA says that the data is not validated.
  • Name the source and its date. Every answer names the recall number, and DATASET.md names the export date. Show both to the people who read the answers.
  • A recall is a report, not a judgement. Some firm names are people’s names. Do not combine the data with other sources to find out about people.
  • Use public services politely. --latest fetches one file from openFDA. Do not point a load test or a crawler at a public data service.

This project shows what employers look for in someone who connects AI applications to data. It has a capability design from real needs, checked data and measured bounds. It also has a contract test and an honest comparison.

What to put in the repository:

food-recalls/
README.md what it is, how to run and test it, the results
DATASET.md the source, the licence, the checksum, the changes, the limits
LICENSE MIT, for the code
docs/capability-catalog.md needs, capabilities, bounds, rejected ideas
docs/results.md your numbers: sizes, the comparison, the bound you fixed
recalls_mcp/ store, contracts, server, logs
host/ the course's host with the mock model
contract/capabilities.json the contract that clients see
questions/eval.jsonl the 22 questions and the expected recall events
scripts/ get_data, contract, sizes, compare
tests/ 32 tests
  1. Make a new Git repository from your project folder, and push it to GitHub. .gitignore keeps out the data, the database and .venv.
  2. Fill in docs/results.md with your own runs. Add one question of your own to questions/eval.jsonl, and say what it showed.
  3. Make sure that python -m scripts.get_data and python -m pytest -q pass from a fresh clone, and say so in the README.

Check your understanding

The median get_recall result (1,098 characters) is as long as the median openFDA record (1,100). Dev says that the bounds are useless. What do you answer?

Show the answer

A bound is not there to make typical answers shorter. It gives every answer a known maximum. Without the bounds, the largest record is 44,645 characters, eleven times the host’s limit of 4,000. With them, the largest get_recall result over all 29,424 recalls is 3,590 characters, and a test checks the largest cases. The median hardly matters: the worst case is what breaks a host.

Check your understanding

With the host's limit removed, all three designs were right on all 22 questions. Does that make the 'everything' design acceptable?

Show the answer

No. For “listeria” it sent 8,345,360 characters to the model, far more than a model can read in one request. Removing the host’s limit only moves the failure from the host to the model. The summary design was right on all 22 questions, with a median of 2,868 characters read. Each of its results fits under 4,000 characters, whatever the host.

Check your understanding

Practical check: what do these print with the finished project? (a) python -m scripts.contract (b) the Inspector CLI call of search_recalls with limit=11 (c) python -m host 'Is there a recall of dragon fruit gummies?'

Show the answer

(a) The server matches contract/capabilities.json. (b) A tool error, with exit code 5: “Input should be less than or equal to 5”. The schema allows a limit of 1 to 5. (c) I found no FDA food recall that matches "dragon fruit gummies" in the data. It says “in the data”, never “safe”.

Recap

  • You checked the data first: the SHA-256 checksum, and 47 records without a valid recall number, left out and listed.
  • You designed the capabilities from people’s needs: two read-only tools, a resource template, a bounded resource list and a prompt.
  • Summaries first, details on request: every tool answer has a known maximum, measured over all 29,424 recalls.
  • A contract test, 32 tests in all, and Inspector in the modern era showed what clients see.
  • Under one measure, only the summary design answered all 22 questions within the host’s limit. A real model found one bound that was too large, and you fixed it.

Next, the other case study: find and fix the weaknesses of a deliberately weak MCP server.

Next: Security review of an MCP integration