Menu

AI

How to Build an MCP Server for SQLite (Python, Read-Only by Default)

Build a SQLite MCP server in Python with the v2 SDK. Open the file read-only, add a schema tool, a query tool and one safe write tool, then test and connect it.

By: Irina ShvayaNovember 2, 2026

How to Build an MCP Server for SQLite (Python, Read-Only by Default)

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=ro URI 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_hint tell 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.db with an orders table.
  • uv or pip.
  • 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" with uri=True raises OperationalError: attempt to write a readonly database on any write. That is your safety net.
  • describe_schema returns each table's CREATE TABLE statement. The model reads column names and types from it.
  • limit has bounds through Field(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.
  • ToolError turns a SQLite error into a readable tool result instead of a crash.
  • read_only_hint=True is 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:

  1. Literal becomes an enum in the input schema. The model can only pick one of the three statuses.
  2. ? placeholders. The sqlite3 docs say: "Always use placeholders instead of string formatting to bind Python values to SQL statements, to avoid SQL injection attacks."
  3. Annotations. destructive_hint=False and idempotent_hint=True say 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

  1. Relative paths. shop.db works in your terminal and fails in Claude Desktop, which starts from another folder. Use absolute paths.
  2. v1 imports. from mcp.server.fastmcp import FastMCP fails on SDK v2. The SDK's What's New page says the old path is "gone rather than deprecated."
  3. Printing debug output. On stdio, stdout is the protocol channel. Use logging, which writes to stderr.
  4. Write tools that take SQL. A tool called execute with a free-form sql argument undoes all the safety above. Keep writes specific.
  5. 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.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.