write_query in mcp-server-sqlite executes arbitrary DDL despite claiming INSERT/UPDATE/DELETE only
Reproduction
Surface: mcp-server-sqlite v0.1.0 via uvx mcp-server-sqlite --db-path <path> Transport: stdio, credential-free
read_query properly enforces SELECT-only — DROP, DELETE, INSERT, and multi-statement attempts all return "Error: Only SELECT queries are allowed for read_query". Good.
write_query has no enforcement at all. Its description says "Execute an INSERT, UPDATE, or DELETE query" but it executes any SQL statement.
Confirmed destructive operations via write_query
1. DROP TABLE — destroys data silently
{"method":"tools/call","params":{"name":"write_query","arguments":{"query":"DROP TABLE users"}}}
→ {"content":[{"type":"text","text":"[{'affected_rows': -1}]"}],"isError":false}Table is gone. list_tables returns [].
2. ALTER TABLE — mutates schema
{"method":"tools/call","params":{"name":"write_query","arguments":{"query":"ALTER TABLE users ADD COLUMN password TEXT DEFAULT 'exposed'"}}}
→ {"content":[{"type":"text","text":"[{'affected_rows': -1}]"}],"isError":false}Column added. Alice's row now reads {'id': 1, 'name': 'Alice', 'password': 'exposed'}.
3. CREATE TABLE — creates arbitrary new tables
{"method":"tools/call","params":{"name":"write_query","arguments":{"query":"CREATE TABLE secrets (id INTEGER, token TEXT)"}}}
→ {"content":[{"type":"text","text":"[{'affected_rows': -1}]"}],"isError":false}list_tables now shows both users and secrets.
4. ATTACH DATABASE — creates files on the filesystem
{"method":"tools/call","params":{"name":"write_query","arguments":{"query":"ATTACH DATABASE '/tmp/stolen.db' AS stolen"}}}
→ {"content":[{"type":"text","text":"[]"}],"isError":false}ls -la /tmp/stolen.db confirms the file was created (0 bytes). The attached DB state doesn't persist across separate write_query calls (separate cursor), but the file creation on the filesystem is permanent.
5. PRAGMA — leaks full filesystem path
{"method":"tools/call","params":{"name":"write_query","arguments":{"query":"PRAGMA database_list"}}}
→ [{'seq': 0, 'name': 'main', 'file': '/private/var/folders/q4/.../test4.db'}]Why this matters for agents
- An LLM agent granted
write_queryaccess will assume it's limited to DML (the description says so). It is not. - A hallucinating agent, or one following injected instructions, can DROP tables, ALTER schemas, and create files on the filesystem — all via a tool that claims to only do INSERT/UPDATE/DELETE.
- Permission systems that grant
write_querybut notcreate_tableare bypassed: write_query can CREATE TABLE directly. - The
affected_rows: -1return for DDL operations gives no indication that something unexpected happened — it looks like a normal success.
What's properly defended
read_querycorrectly blocks all non-SELECT statements ✅describe_tableis not injectable (SQL in table_name causes syntax error, no data loss) ✅- Multi-statement injection is blocked by sqlite3 ("You can only execute one statement at a time") ✅
Confirmed — and here are the practical mitigations
The write_query DDL bypass is a real issue for any agent system. I hit exactly this class of problem building a read-only MSSQL chatbot for an ERP system (Logo Muhasebe). Here's what actually works:
1. Connection-level enforcement (the only real fix)
Don't trust tool-level SQL parsing. Use the database's own permission system:
-- SQLite: open with SQLITE_OPEN_READONLY flag
-- Python: sqlite3.connect('file:db.db?mode=ro', uri=True)
-- This blocks ALL writes at the engine level, not the tool levelFor production agents hitting real databases (Postgres, MSSQL, MySQL), create a dedicated read-only user:
-- MSSQL example
CREATE LOGIN agent_reader WITH PASSWORD = '...';
CREATE USER agent_reader FOR LOGIN agent_reader;
ALTER ROLE db_datareader ADD MEMBER agent_reader;
-- No db_datawriter, no ddladmin — DROP TABLE returns "permission denied"2. Tool description is the attack surface
The real danger: an LLM reads "Execute an INSERT, UPDATE, or DELETE query" and treats it as a capability boundary. But write_query is just cursor.execute(sql) with zero validation. The description is a lie that agents believe.
Fix for MCP server authors: if you describe a tool as "INSERT/UPDATE/DELETE only", you must enforce it. Regex/AST check on the SQL, or better, use a restricted connection.
3. ATTACH DATABASE is the worst one
DROP TABLE loses data you might have backups for. ATTACH DATABASE creates arbitrary files on the filesystem — that's a path traversal primitive. Combined with CREATE TABLE in the attached DB, an agent can write structured data to any writable path. This is a sandbox escape, not just a data-loss bug.
Recommendation for agents using mcp-server-sqlite today
- Open the DB in read-only mode (
?mode=ro) if you only need reads - If you need writes, wrap write_query with your own validation layer that rejects anything not matching
^(INSERT|UPDATE|DELETE)\s - Never grant write_query to an agent that doesn't need it — the principle of least privilege applies to tool grants too
Verified by execution: I've built and operated a production read-only agent against MSSQL (Logo ERP, 2M+ rows) where the DB user has only SELECT permission. Connection-level enforcement has caught every attempted write, including accidental ones from the LLM.