TT Lab
Get started
Learn Learning paths Courses

The agent dropped my database

Reproduce and stop the agent-drops-the-database incident

Continue in TT Lab

Goal

After reproducing an MCP server that does anything through a single run_sql and executes DROP TABLE as is, you fix the same server in three layers: read-only mode, an allowlist, and a confirmation step for destructive tools.

Why it matters

The incident in the title does not happen because the model is bad. A model that received "clean up the test data for me" simply picked the shortest path, DROP TABLE, and the cause is that the server had no safeguard to stop it. The specification calls tools model-controlled, so a human must be able to deny them and the server must implement access control. Translated into code, that sentence becomes three things: split tools narrowly and switch them on and off with a list; where writing is not needed, open the connection itself as read-only; and make a tool that deletes refuse to run without confirmation. In this lab you put in all three by hand.

Steps

  1. Save /root/mcp/guard/seed.sql and load it into /root/mcp/guard/shop.db. There must be 5 rows in customers and 8 rows in orders.
  2. Create /root/mcp/guard/server_v1.py. It has a single tool, run_sql (argument sql); for a SELECT it returns the result rows, and for any other SQL it runs the statement and returns ok. The DB path is the environment variable MCP_DB (default /root/mcp/guard/shop.db).
  3. Reproduce the incident with /root/mcp/guard/attack.sh. Send DROP TABLE orders; to the v1 server through run_sql, and leave the response JSON and one line tables_after=<남은 테이블 목록> (the placeholder stands for the list of remaining tables) in /root/mcp/guard/incident.txt. orders must really disappear.
  4. Restore the DB from seed.sql and write three lines (each at least one sentence), cause=, missing_control= and fix=, in /root/mcp/guard/postmortem.md.
  5. /root/mcp/guard/server_v2.py — when the environment variable MCP_READ_ONLY=1 is set, open the DB read-only, so that even if a DROP is sent through run_sql it is rejected with isError: true and the table survives. A SELECT still works.
  6. /root/mcp/guard/server_v3.py and /root/mcp/guard/allowlist.json — remove run_sql and define three tools, list_customers, count_orders and delete_order, but list in tools/list and accept calls only for the names written in the environment variable MCP_ALLOWLIST (default allowlist.json). Put only the two read tools in allowlist.json. A call to a tool outside the list is -32602.
  7. In v3, delete_order (arguments id and confirm) deletes nothing and tells you, through isError: true, what it was going to delete unless confirm is true, and deletes only when it is true.
  8. Send the same attack to v3 with /root/mcp/guard/attack_v3.sh and confirm that it is stopped, then write four lines in /root/mcp/guard/guard-report.txt: run_sql_removed=yes, readonly_blocks_drop=yes, delete_requires_confirm=yes and orders_rows=<현재 orders 행 수> (the placeholder stands for the current number of rows in orders).

Notes

Create the shop DB

Save /root/mcp/guard/seed.sql and load it into /root/mcp/guard/shop.db. There must be 5 rows in customers and 8 rows in orders.

With sqlite3, sqlite3 shop.db < seed.sql runs the whole file. In Python, use sqlite3.connect(...).executescript(open(...).read()). Loading again into a DB that already exists gives an error saying the table exists, so delete it first.

A server with a catch-all tool

Create /root/mcp/guard/server_v1.py. It has a single tool, run_sql (argument sql); for a SELECT it returns the result rows, and for any other SQL it runs the statement and returns ok. The DB path is the environment variable MCP_DB (default /root/mcp/guard/shop.db).

Change the tool in the previous lab's server to a single run_sql. If the statement starts with SELECT, use execute().fetchall(); otherwise executescript() followed by commit(). Return sqlite errors as isError: true. This server is deliberately left dangerous — you will see the result in the next step.

Reproduce the incident

Reproduce the incident with /root/mcp/guard/attack.sh. Send DROP TABLE orders; to the v1 server through run_sql, and leave the response JSON and one line tables_after=<남은 테이블 목록> (the placeholder stands for the list of remaining tables) in /root/mcp/guard/incident.txt. orders must really disappear.

Build two request lines (initialize and tools/call) with printf and pipe them into python3 server_v1.py; along with the output, read the remaining table names from sqlite_master and append them as one line. Leave in the file the evidence that the response has no isError and that ok comes back — proof that the server deleted it without any resistance.

Recover and write the incident record

Restore the DB from seed.sql and write three lines (each at least one sentence), cause=, missing_control= and fix=, in /root/mcp/guard/postmortem.md.

Recovery is the same as in step 1 (delete the file and load it again). The incident record answers three questions: what was the cause (tool design), which controls were missing (allowlist, read-only, confirmation), and what will you change.

Read-only mode

/root/mcp/guard/server_v2.py — when the environment variable MCP_READ_ONLY=1 is set, open the DB read-only, so that even if a DROP is sent through run_sql it is rejected with isError: true and the table survives. A SELECT still works.

If you open with sqlite3.connect(f"file:{DB}?mode=ro", uri=True), write statements fail with a sqlite error. Catch that exception and return it as isError: true text. Checking whether the string contains DROP leaves you an endless list of variants to follow, so choose blocking at the connection itself.

Narrow it down with an allowlist

/root/mcp/guard/server_v3.py and /root/mcp/guard/allowlist.json — remove run_sql and define three tools, list_customers, count_orders and delete_order, but list in tools/list and accept calls only for the names written in the environment variable MCP_ALLOWLIST (default allowlist.json). Put only the two read tools in allowlist.json. A call to a tool outside the list is -32602.

Keep the full set of tool definitions (ALL_TOOLS) separate from TOOLS, which you get by reading the list file and filtering. In tools/call as well, a name that is not in TOOLS is answered as an unknown tool. If the list file is missing or broken, use an empty set — exposing no tools at all is the safe default.

A tool that deletes needs confirmation

In v3, delete_order (arguments id and confirm) deletes nothing and tells you, through isError: true, what it was going to delete unless confirm is true, and deletes only when it is true.

Compare with args.get("confirm") is True. When refusing, put the row it was going to delete (status and amount) in the text so that a person has material to decide with, and when it did delete, return the rowcount. The grader tests against a temporary copy of the DB passed in through MCP_DB, and gives a temporary allowlist that includes delete_order through MCP_ALLOWLIST.

Prove that the same attack is stopped

Send the same attack to v3 with /root/mcp/guard/attack_v3.sh and confirm that it is stopped, then write four lines in /root/mcp/guard/guard-report.txt: run_sql_removed=yes, readonly_blocks_drop=yes, delete_requires_confirm=yes and orders_rows=<현재 orders 행 수> (the placeholder stands for the current number of rows in orders).

Copy attack.sh, change it to v3, and add one more line with a request that calls delete_order without confirm. In the responses, run_sql must give -32602 and delete_order must give isError. Count the number of rows with SELECT COUNT(*) FROM orders and write it as is.