DEV Community

Cover image for Give Claude a read-only straw to your SQLite DB: a tiny MCP server in Python
Spicy
Spicy

Posted on

Give Claude a read-only straw to your SQLite DB: a tiny MCP server in Python

RAG was always a bit of a hack. Chunk your files, embed them, cross your fingers the right snippet comes back. Fine for fuzzy "what did this doc say" questions. Terrible for structured data, where you don't want a summary, you want the exact number.

If your data lives in a database, there's a cleaner move in 2026: skip the vectors and let the model query the database directly through MCP (Model Context Protocol). The model writes SQL, your local server runs it, you get the real answer back. Nothing gets uploaded to anyone's cloud.

Here's a minimal, working MCP server that does exactly that for SQLite. About 30 lines.

The server

​

# server.py
import sqlite3
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("local-sqlite")
DB_PATH = "app.db"

@mcp.tool()
def list_tables() -> list[str]:
    """List the tables in the database."""
    con = sqlite3.connect(DB_PATH)
    try:
        rows = con.execute(
            "SELECT name FROM sqlite_master WHERE type='table'"
        ).fetchall()
        return [r[0] for r in rows]
    finally:
        con.close()

@mcp.tool()
def run_query(sql: str) -> list[dict]:
    """Run a read-only SQL query and return the rows."""
    if not sql.lstrip().lower().startswith("select"):
        raise ValueError("Only SELECT queries are allowed.")
    con = sqlite3.connect(DB_PATH)
    con.row_factory = sqlite3.Row
    try:
        return [dict(r) for r in con.execute(sql).fetchall()]
    finally:
        con.close()

if __name__ == "__main__":
    mcp.run()
​```
{% endraw %}


Install the dependency and that's it:

​
{% raw %}
```bash
pip install "mcp[cli]"
​```



## Wiring it into Claude Desktop

Drop this into your `claude_desktop_config.json`:

​

```json
{
  "mcpServers": {
    "local-sqlite": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}
​```



Restart Claude Desktop and you can now ask "which five customers spent the most last month?" The model calls `list_tables`, works out the schema, writes the SQL, calls `run_query`, and answers with real rows. Not a guess pulled from an embedded chunk. The actual data.

## The one line that matters most

This one:

​

```python
if not sql.lstrip().lower().startswith("select"):
    raise ValueError("Only SELECT queries are allowed.")
​```



You're handing an LLM the ability to run SQL on your database. Without a guard, a creative prompt, or a prompt injection hidden inside your own data, could talk it into {% raw %}`DROP TABLE`{% endraw %}. Keeping the server read-only is the cheapest, most important rail you can add. If you need writes, put them behind a separate, explicit tool with its own checks. Never through the open query path.

A few things I'd add before trusting this with anything real:

- Open the connection read-only at the driver level too, not just by string-checking the query.
- Enforce a row limit so a `SELECT *` on a huge table doesn't flood the context window.
- Log every query the model runs, so you can see what it actually did.

That's the whole idea. The model gets a straw to the data, not a copy of it, and your database never leaves the machine.

I wrote up the bigger picture, the zero-trust angle and why this beats RAG for structured data, here: [The Local Data Architect: Connecting Your Private Database to AI via MCP](https://lucas8.com/mcp-local-data-architect-guide-postgresql-sqlite/)

Anyone else ripping out a RAG pipeline for direct MCP access? Curious what guardrails you're putting on the query tool.
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
galagent profile image
Qingfeng •

The startswith check is the right instinct, but it is a string test, not a permission boundary. I hit this in production: a model sent a select, then a semicolon, then a second statement, and the driver ran both happily. Two things I now treat as non negotiable. First, open the connection with the read only URI flag and wrap every call in a transaction that is always rolled back, so even a write that slips through evaporates. Second, insert a preview step: the tool returns the parsed plan plus an estimated row count, the harness shows that to a human, and only then does execution run. My open question: how do you cap runaway loops when the model keeps re querying to repair its own SQL errors?