
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
An MCP server for SQLite is one of the easiest useful servers you can build. SQLite is a single file, Python ships the driver, and SQLite can refuse writes for you. In about 60 lines you get a server that lets Claude, Cursor or another MCP host explore a local database and answer questions about it.
This guide builds that server with the official Python SDK, version 2. It opens the database read-only for queries, then adds one narrow write tool with typed inputs. You will test it in memory and with the MCP Inspector, then connect it to a host.
Checked against: the Python SDK docs and the Python sqlite3 documentation on 2 November 2026, with mcp 2.3.0 (the latest PyPI release). The code ran locally on 2 November 2026 on Python 3.12, including an Inspector CLI call (Inspector 2.13.0).
Key Takeaways
- Open SQLite with a
file:...?mode=roURI for every read. SQLite itself then rejects writes, whatever SQL the model sends. - Expose a schema tool so the model learns table and column names before it writes queries.
- If you need writes, add a specific tool such as "set order status" with an enum and parameter binding, not a general "execute SQL" tool.
- Tool annotations such as
read_only_hinttell hosts which tools are safe, but they are hints, not security. - The old reference SQLite server is archived, so a small server you own is a reasonable choice.
When SQLite plus MCP makes sense
SQLite works well for local data: an exported CRM, an analytics extract, a mobile app's database or a prototype. Your AI assistant can answer "which customers spent the most last quarter" without you writing SQL.
The original reference SQLite server is in the archived servers repository, read-only since 29 May 2025, with "NO SECURITY GUARANTEES." That makes building your own small server a sensible path. If your data lives in a hosted database, see building an MCP server for PostgreSQL or our list of the best MCP servers for databases.
Prerequisites
- Python 3.11 or newer (the SDK README says 3.11+).
- A SQLite file. This guide uses
shop.dbwith anorderstable. uvorpip.- Node.js 22.19+ if you want the Inspector CLI.
New to MCP tools? Lesson 14 of our course explains MCPServer and @mcp.tool() step by step.
Install
uv init sqlite-mcp --python 3.12
cd sqlite-mcp
uv add "mcp[cli]"
That is the only dependency. sqlite3 is part of the Python standard library.
Minimal working server
Create server.py:
import os
import sqlite3
from typing import Annotated, Any
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
DB_PATH = os.environ.get("SQLITE_PATH", "shop.db")
mcp = MCPServer("sqlite-shop")
def read_conn() -> sqlite3.Connection:
# mode=ro makes SQLite itself refuse writes on this connection
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def describe_schema() -> list[dict[str, Any]]:
"""List every table and the SQL that created it."""
with read_conn() as conn:
rows = conn.execute("SELECT name, sql FROM sqlite_schema WHERE type = 'table'")
return [dict(r) for r in rows]
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def query(
sql: Annotated[str, Field(description="A single SELECT statement.")],
limit: Annotated[int, Field(ge=1, le=500)] = 100,
) -> list[dict[str, Any]]:
"""Run a read-only SQL query and return up to `limit` rows."""
try:
with read_conn() as conn:
rows = conn.execute(sql).fetchmany(limit)
except sqlite3.Error as exc:
raise ToolError(f"SQLite error: {exc}") from exc
return [dict(r) for r in rows]
if __name__ == "__main__":
mcp.run()
How it works:
mode=ro. The Python docs show that a connection opened as"file:tutorial.db?mode=ro"withuri=TrueraisesOperationalError: attempt to write a readonly databaseon any write. That is your safety net.describe_schemareturns each table'sCREATE TABLEstatement. The model reads column names and types from it.limithas bounds throughField(ge=1, le=500). The SDK's Tools guide says constraints like these are enforced before your function runs, and the error goes back to the model so it can retry.ToolErrorturns a SQLite error into a readable tool result instead of a crash.read_only_hint=Trueis a tool annotation. The Tools guide calls annotations hints that a well-behaved client may use to decide whether to ask the user first. It also says to never rely on a client honouring them.
Add a safe write tool
Most teams eventually want one or two writes. Do not add an "execute any SQL" tool. Add a tool for each job:
from typing import Literal
@mcp.tool(annotations=ToolAnnotations(destructive_hint=False, idempotent_hint=True))
def set_order_status(
order_id: int,
status: Literal["pending", "shipped", "cancelled"],
) -> str:
"""Change one order's status. The only write this server allows."""
with sqlite3.connect(DB_PATH) as conn:
cur = conn.execute("UPDATE orders SET status = ? WHERE id = ?", (status, order_id))
if cur.rowcount == 0:
raise ToolError(f"No order with id {order_id}.")
return f"Order {order_id} is now {status}."
Three things keep it safe:
Literalbecomes an enum in the input schema. The model can only pick one of the three statuses.?placeholders. Thesqlite3docs say: "Always use placeholders instead of string formatting to bind Python values to SQL statements, to avoid SQL injection attacks."- Annotations.
destructive_hint=Falseandidempotent_hint=Truesay the tool does not delete and that calling it twice has the same effect as once. Our page on MCP tool annotations explains each hint.
Note that with sqlite3.connect(...) as conn commits the transaction but does not close the connection. For a long-running server, close connections explicitly or keep one per process.
Test it in memory
The SDK's Client can talk to your server object directly, with no subprocess. Create check.py:
import anyio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as c:
r = await c.call_tool("query", {
"sql": "SELECT customer, SUM(total) AS spent FROM orders GROUP BY customer"})
print(r.structured_content)
r = await c.call_tool("query", {"sql": "DELETE FROM orders"})
print(r.is_error, r.content[0].text)
r = await c.call_tool("set_order_status", {"order_id": 2, "status": "shipped"})
print(r.content[0].text)
r = await c.call_tool("set_order_status", {"order_id": 99, "status": "shipped"})
print(r.is_error, r.content[0].text)
anyio.run(main)
The output on 2 November 2026 with a three-row orders table:
{'result': [{'customer': 'Ana', 'spent': 135.75}, {'customer': 'Ben', 'spent': 40.0}]}
True Error executing tool query: SQLite error: attempt to write a readonly database
Order 2 is now shipped.
True Error executing tool set_order_status: No order with id 99.
The DELETE went through the query tool and SQLite refused it. The write tool changed one row and reported a missing ID clearly. A call with "status": "lost" also failed, with a validation error, before the function ran.
Test it with the MCP Inspector
The MCP Inspector CLI runs your server over stdio like a real host:
npx @modelcontextprotocol/inspector --cli \
uv --directory /ABSOLUTE/PATH/sqlite-mcp run server.py \
--method tools/call --tool-name query \
--tool-args-json '{"sql":"SELECT count(*) AS n FROM orders"}' --format json
On 2 November 2026 the result included "structuredContent":{"result":[{"n":3}]} and "isError":false. Our Inspector lesson covers listing tools, exit codes and testing both protocol eras.
Connect it to a host
Set SQLITE_PATH to an absolute path, because hosts start servers from their own working directory. For Claude Code:
claude mcp add -e SQLITE_PATH=/ABSOLUTE/PATH/shop.db --transport stdio sqlite-shop \
-- uv run --directory /ABSOLUTE/PATH/sqlite-mcp server.py
The Claude Code MCP docs say options go before the server name and the launch command goes after --. They also warn that if the name comes directly after --env, the CLI reads it as another variable, which is why --transport stdio sits in between.
For Claude Desktop, Cursor or VS Code, the SDK's real-host guide gives the config file for each. Our MCP config generator builds the JSON for you. Restart the host fully after editing a config file.
Deploy notes
SQLite is a local database, so this server is usually a local stdio server. That is a good fit: the file never leaves the machine, and no network sign-in is needed.
If several people need the same data, do not share a SQLite file over a network drive. Move the data to PostgreSQL or another server database and run a remote MCP server with sign-in. The Learn MCP lesson on remote servers covers Streamable HTTP deployment.
Pitfalls
- Relative paths.
shop.dbworks in your terminal and fails in Claude Desktop, which starts from another folder. Use absolute paths. - v1 imports.
from mcp.server.fastmcp import FastMCPfails on SDK v2. The SDK's What's New page says the old path is "gone rather than deprecated." - Printing debug output. On stdio, stdout is the protocol channel. Use
logging, which writes to stderr. - Write tools that take SQL. A tool called
executewith a free-formsqlargument undoes all the safety above. Keep writes specific. - Locked database. If another app holds a write lock, SQLite returns "database is locked." Report it as a tool error and let the user retry.
Next step
Work through the free Learn MCP course to add resources, prompts and remote deployment to this server. If you want a database server built around your own data and approval rules, eSEOspace MCP development can help scope it. For spreadsheet data, see building an MCP server for CSV data.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Does mode=ro stop every write?
It stops writes through that connection, as the Python docs example shows with a CREATE TABLE that fails. It does not stop other programs from changing the file. For a fully frozen copy, point the server at a snapshot file.
Can the model see tables I want to keep private?
Yes, if they are in the same file. SQLite has no per-table permissions. Copy only the tables you want to expose into a separate database file, and point the server at that.
Should the query tool block anything except SELECT?
You can add a simple check for a friendlier error, but the read-only connection already blocks writes. Rely on mode=ro, not on parsing SQL text.
Can I return results as a table instead of JSON?
Yes. Return a string with a Markdown table if your host renders it well. Returning a list of dicts gives both the model and the host structured data, which is usually more useful.
Is SQLite safe for a multi-user remote server?
SQLite handles many readers well, but it is a single file on one machine. For a shared team server with writes, a server database such as PostgreSQL is easier to secure and scale.











