Reproduce and stop the agent-drops-the-database incident
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
- Save
/root/mcp/guard/seed.sqland load it into/root/mcp/guard/shop.db. There must be 5 rows in customers and 8 rows in orders. - Create
/root/mcp/guard/server_v1.py. It has a single tool,run_sql(argumentsql); for a SELECT it returns the result rows, and for any other SQL it runs the statement and returnsok. The DB path is the environment variableMCP_DB(default/root/mcp/guard/shop.db). - Reproduce the incident with
/root/mcp/guard/attack.sh. SendDROP TABLE orders;to the v1 server throughrun_sql, and leave the response JSON and one linetables_after=<남은 테이블 목록>(the placeholder stands for the list of remaining tables) in/root/mcp/guard/incident.txt. orders must really disappear. - Restore the DB from seed.sql and write three lines (each at least one sentence),
cause=,missing_control=andfix=, in/root/mcp/guard/postmortem.md. /root/mcp/guard/server_v2.py— when the environment variableMCP_READ_ONLY=1is set, open the DB read-only, so that even if aDROPis sent throughrun_sqlit is rejected withisError: trueand the table survives. A SELECT still works./root/mcp/guard/server_v3.pyand/root/mcp/guard/allowlist.json— removerun_sqland define three tools,list_customers,count_ordersanddelete_order, but list intools/listand accept calls only for the names written in the environment variableMCP_ALLOWLIST(default allowlist.json). Put only the two read tools in allowlist.json. A call to a tool outside the list is-32602.- In v3,
delete_order(argumentsidandconfirm) deletes nothing and tells you, throughisError: true, what it was going to delete unlessconfirmistrue, and deletes only when it istrue. - Send the same attack to v3 with
/root/mcp/guard/attack_v3.shand 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=yesandorders_rows=<현재 orders 행 수>(the placeholder stands for the current number of rows in orders).
Notes
- Read-only connection:
sqlite3.connect(f"file:{경로}?mode=ro", uri=True)(the placeholder stands for the path to the DB file). If you try to write, sqlite rejects it withattempt to write a readonly database— blocking by inspecting the SQL string leaves you an endless list of variants to follow, so block it in the engine. - When the grader tests a destructive tool, it copies the student's DB to a temporary copy and passes it in through
MCP_DB. If the server does not read that environment variable, grading deletes the real DB or fails. - An allowlist is not "which of the existing tools to enable" but "if it is not on the list, the tool does not exist". It must not appear in
tools/list, andtools/callmust also answer as an unknown tool. - Common mistake 1: accepting confirm as the string
"true". If the schema says boolean, compare withis True. - Common mistake 2: opening every tool when the list is missing. Opening nothing when it is missing is the closed default.
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.