{"slug": "how-i-built-a-read-only-sqlite-mcp-server-in-python-and-why-read-only-matters", "title": "How I Built a Read-Only SQLite MCP Server in Python (and Why Read-Only Matters)", "summary": "A developer built a read-only SQLite MCP server in Python to allow AI agents like Claude Desktop to query databases without write access. The server uses two independent layers of protection: opening the database in read-only mode at the engine level and rejecting non-SELECT queries before execution. The design separates safety logic from the MCP protocol for easier testing and reliability.", "body_md": "Giving an LLM a database connection is one of those ideas that sounds great in a demo and terrifying in production. The agent writes a slightly-wrong query, and now you're explaining to your team why `orders`\n\nis empty.\n\nSo when I wanted an AI agent (Claude Desktop, in my case) to answer questions about a SQLite database, I didn't want to hand it a read-write connection and hope for the best. I built a small **MCP server** that gives the agent *read-only* SQL access — and I made \"read-only\" mean it, with two independent layers of protection.\n\nHere's how it works, and the design decisions that matter.\n\nFull source:\n\n(MIT).[github.com/skycandykey1/mcp-sqlite-server]\n\nThe ** Model Context Protocol** (MCP) is an open standard for connecting AI apps to tools and data. An MCP\n\n`query`\n\n, `list_tables`\n\n, ...)The Python SDK ships a high-level helper, `FastMCP`\n\n, that turns this into a few decorators. The interesting part isn't the protocol — it's the safety design *behind* the tools.\n\nThe first decision: **the read-only safety logic has zero MCP dependency.** It lives in a plain module (`db.py`\n\n) that knows nothing about MCP, so I can unit-test it with nothing but the standard library. The server (`server.py`\n\n) is a thin wrapper.\n\nThat separation matters: the part that must never be wrong (write protection) is testable in isolation, without spinning up an MCP client.\n\nA single guard is a single point of failure. So write protection happens twice, independently.\n\n**Layer 1 — open the database read-only at the engine level:**\n\n``` php\nimport sqlite3\n\ndef connect(path: str) -> sqlite3.Connection:\n    \"\"\"Open a SQLite database in READ-ONLY mode. Any write raises OperationalError.\"\"\"\n    conn = sqlite3.connect(f\"file:{path}?mode=ro\", uri=True)\n    conn.row_factory = sqlite3.Row\n    conn.execute(\"PRAGMA query_only = ON\")  # defense in depth\n    return conn\n```\n\n`?mode=ro`\n\ntells SQLite to open the file read-only; `PRAGMA query_only`\n\nis a second belt on the same trousers. Any `INSERT`\n\n/`UPDATE`\n\n/`DELETE`\n\nraises `OperationalError`\n\nfrom the engine itself.\n\n**Layer 2 — reject anything that isn't a single SELECT before it runs:**\n\n``` php\ndef _ensure_read_only(sql: str) -> str:\n    stmt = sql.strip().rstrip(\";\").strip()\n    if not stmt:\n        raise ValueError(\"empty query\")\n    if \";\" in stmt:\n        raise ValueError(\"only a single statement is allowed\")\n    head = stmt.lower()\n    if not (head.startswith(\"select\") or head.startswith(\"with\")):\n        raise ValueError(\"only read-only SELECT / WITH queries are allowed\")\n    return stmt\n```\n\nThis gives the model a clean, early error message (\"only read-only SELECT / WITH queries are allowed\") instead of a raw engine exception, and it blocks multi-statement tricks. Even if a future refactor weakens this guard, Layer 1 still holds — and vice versa.\n\nThe query runner ties it together and caps the row count so a `SELECT *`\n\non a huge table can't blow up the context window:\n\n``` python\ndef run_query(conn, sql, max_rows=100):\n    stmt = _ensure_read_only(sql)\n    max_rows = max(1, min(int(max_rows), 1000))\n    cur = conn.execute(stmt)\n    cols = [d[0] for d in cur.description] if cur.description else []\n    rows = cur.fetchmany(max_rows)\n    return {\n        \"columns\": cols,\n        \"rows\": [dict(zip(cols, r)) for r in rows],\n        \"row_count\": len(rows),\n        \"truncated\": len(rows) == max_rows,\n    }\n```\n\nWith the core done, the server is almost boring — which is the point:\n\n``` python\nimport os\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp_sqlite.db import session, list_tables as _list, run_query as _run\n\nmcp = FastMCP(\"sqlite-readonly\")\n\ndef _db_path() -> str:\n    path = os.environ.get(\"SQLITE_DB_PATH\")\n    if not path:\n        raise RuntimeError(\"Set SQLITE_DB_PATH to your .db file.\")\n    return path\n\n@mcp.tool()\ndef list_tables() -> list[str]:\n    \"\"\"List all tables in the database.\"\"\"\n    with session(_db_path()) as conn:\n        return _list(conn)\n\n@mcp.tool()\ndef query(sql: str, max_rows: int = 100) -> dict:\n    \"\"\"Run a READ-ONLY SQL query (a single SELECT or WITH) and return the rows.\"\"\"\n    with session(_db_path()) as conn:\n        return _run(conn, sql, max_rows)\n\nif __name__ == \"__main__\":\n    mcp.run()  # stdio transport\n```\n\nThe full version also exposes a `schema://tables`\n\n**resource** (the whole schema as text) and an `explore_database`\n\n**prompt** — all three MCP primitives, so the agent can discover the database on its own.\n\nAdd this to your Claude Desktop config (**Settings → Developer → Edit Config**), using absolute paths:\n\n```\n{\n  \"mcpServers\": {\n    \"sqlite-readonly\": {\n      \"command\": \"python\",\n      \"args\": [\"-m\", \"mcp_sqlite.server\"],\n      \"cwd\": \"/absolute/path/to/mcp-sqlite-server\",\n      \"env\": { \"SQLITE_DB_PATH\": \"/absolute/path/to/your.db\" }\n    }\n  }\n}\n```\n\nRestart, then ask: *\"What tables are in my database? Show me the top 5 orders by amount.\"* The agent calls `list_tables`\n\n, reads the schema resource, writes a `SELECT`\n\n, and answers — and physically cannot write.\n\nThe most important test isn't that `SELECT`\n\nworks — it's that a write *fails even if the statement guard is bypassed*:\n\n``` python\ndef test_readonly_connection_blocks_writes_even_if_guard_bypassed():\n    with db.session(_make_db()) as c:  # _make_db() builds a temp sample DB\n        try:\n            c.execute(\"INSERT INTO customers (name) VALUES ('x')\")\n            assert False, \"read-only connection should refuse writes\"\n        except sqlite3.OperationalError:\n            pass\n```\n\nThat's the whole value proposition in one test: defense in depth, proven.\n\nThe full project — tests, a sample database, and the Claude Desktop config — is on GitHub: ** mcp-sqlite-server**. If you're building agents, you might also like my minimal, framework-free\n\n*I build AI agents, MCP servers, and LLM automation, and I take on contract work. If you're putting an agent in front of real systems and want it done safely, get in touch.*", "url": "https://wpnews.pro/news/how-i-built-a-read-only-sqlite-mcp-server-in-python-and-why-read-only-matters", "canonical_source": "https://dev.to/skycandykey1/how-i-built-a-read-only-sqlite-mcp-server-in-python-and-why-read-only-matters-bef", "published_at": "2026-06-14 15:44:35+00:00", "updated_at": "2026-06-14 16:11:04.566105+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "large-language-models", "ai-safety"], "entities": ["Claude Desktop", "SQLite", "MCP", "Python", "FastMCP", "MIT"], "alternates": {"html": "https://wpnews.pro/news/how-i-built-a-read-only-sqlite-mcp-server-in-python-and-why-read-only-matters", "markdown": "https://wpnews.pro/news/how-i-built-a-read-only-sqlite-mcp-server-in-python-and-why-read-only-matters.md", "text": "https://wpnews.pro/news/how-i-built-a-read-only-sqlite-mcp-server-in-python-and-why-read-only-matters.txt", "jsonld": "https://wpnews.pro/news/how-i-built-a-read-only-sqlite-mcp-server-in-python-and-why-read-only-matters.jsonld"}}