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
1. Understand the integration problem
3. Build and inspect a local server
4. Connect an AI application
5. Remote access and authorization
6. Protect tools and user control
7. Operate and maintain the integration
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.
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 ready
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.
The situation
Section titled “The situation”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.
What you will build
Section titled “What you will build”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
Section titled “The data”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.
Set up the project
Section titled “Set up the project”The steps use VS Code and its terminal. Every command runs in the project folder.
-
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 folderfood-recalls. -
In VS Code, choose File > Open Folder, and open
food-recalls. Then open a terminal: Terminal > New Terminal. -
Make and activate a virtual environment, install the packages, and get the data:
Terminal window python3 -m venv .venvsource .venv/bin/activatepython -m pip install -r requirements-dev.txtpython -m scripts.get_dataTerminal window py -m venv .venv.venv\Scripts\Activate.ps1python -m pip install -r requirements-dev.txtpython -m scripts.get_dataTerminal window python3 -m venv .venvsource .venv/bin/activatepython -m pip install -r requirements-dev.txtpython -m scripts.get_dataOutput (end) Checked food-enforcement-2026-10-06.json.zip: SHA-256 fa59ccc9642b... is correctWrote 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.jsonlThe prompt starts with
(.venv). The packages are the course’s versions:mcp2.3.0, pytest 9.1.1 and ruff 0.17.0. -
Run the tests of the starter:
Terminal window python -m pytest -qOutput ......... [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”The checksum
Section titled “The checksum”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.
The IDs that a URI needs
Section titled “The IDs that a URI needs”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”.
Only the exploration that matters
Section titled “Only the exploration that matters”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.
Part 2: the capability catalog
Section titled “Part 2: the capability catalog”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.
Part 3: the server, with bounded results
Section titled “Part 3: the server, with bounded results”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.
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 |
Build it
Section titled “Build it”-
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 = 100MAX_RESULTS = 5DEFAULT_RESULTS = 5SUMMARY_CHARS = 200 # product and reason in a search resultDETAIL_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,SearchResultandRecallmodels, and the text of the prompt. -
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 Nonelog.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_recallraisesToolError("not_found: …")for a number that is not in the data. The resource raisesResourceNotFoundError, 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 finishedserver.py. Use the switch only for the comparison. -
Add
recalls_mcp/__main__.py, which runs the server on stdio, andrecalls_mcp/logs.py, which writes one JSON line per call to stderr. On stdio, stdout belongs to the protocol. -
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 answerRecall 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:
python -m scripts.contract --writepython -m scripts.contractWrote capabilities.json: 2 tools, 1 resource template, 1 promptThe 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:
python -m pytest -q................................ [100%]32 passed in 1.05sThe 23 new tests check what this case study promised:
- Bounds. A
limitof 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.
H1393breaks the pattern.F-9999-2001isnot_found. A missing resource is −32602, withdata.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_mcpas a child process, in revision 2026-07-28 and in the legacyinitializeera. - 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.
python -m scripts.sizesanswer count median p95 p99 max (characters)openFDA record (no bound) 29,471 1,100 1,636 2,451 44,645get_recall result 29,424 1,098 1,627 2,105 3,590recall resource text 29,424 831 1,369 2,184 7,713search_recalls, limit 5 20 2,423 2,711 2,820 2,820resources/list: 50 recalls (bound 50)- no bound
- bounded by the server
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.
Part 5: inspect it in the modern era
Section titled “Part 5: inspect it in the modern era”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:
npx @modelcontextprotocol/inspector@2.10.1 --cli python -m recalls_mcp -- --protocol-era modern --method tools/listnpx @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=11npx @modelcontextprotocol/inspector@2.10.1 --cli python -m recalls_mcp -- --protocol-era modern --method resources/read --uri recall://fda-food/F-9999-2001"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{"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.
python -m host "Is there a recall of dragon fruit gummies?"python -m host "What is recall H-1393-2026 about?"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:
RECALLS_DESIGN=full python -m host "Is there a listeria recall?"$env:RECALLS_DESIGN = "full"python -m host "Is there a listeria recall?"Remove-Item Env:RECALLS_DESIGNRECALLS_DESIGN=full python -m host "Is there a listeria recall?"[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.
python -m scripts.compare22 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,314full 10/22 12 4,256 / 4,456 4,278 / 8,278summary 22/22 0 2,868 / 4,464 3,096 / 4,720- host limit 4,000 characters
- no host limit
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.
The recommendation
Section titled “The recommendation”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.
Part 8: new questions, end to end
Section titled “Part 8: new questions, end to end”The comparison used fixed questions. Now ask questions that were not in it. With the mock model:
python -m host "Are half shell oysters recalled?"python -m host "Is kombucha under a recall?"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.
A real model (optional)
Section titled “A real model (optional)”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:
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 charactersA: 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.
Insights and limits
Section titled “Insights and limits”- 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.
Responsible use
Section titled “Responsible use”- “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_answerprompt 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.mdnames 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.
--latestfetches one file from openFDA. Do not point a load test or a crawler at a public data service.
Make it a portfolio piece
Section titled “Make it a portfolio piece”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- Make a new Git repository from your project folder, and push it to GitHub.
.gitignorekeeps out the data, the database and.venv. - Fill in
docs/results.mdwith your own runs. Add one question of your own toquestions/eval.jsonl, and say what it showed. - Make sure that
python -m scripts.get_dataandpython -m pytest -qpass from a fresh clone, and say so in the README.
Knowledge check
Section titled “Knowledge check”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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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”.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.