How to Build a Secure SQLite MCP Server in Python with FastMCP

Dileep Solanki


AI agents become much more useful when they can work with real data instead of only generating text. A database is one of the most common sources of structured information developers may want to connect to an AI application.

The Model Context Protocol (MCP) provides a standardized way for AI applications to interact with external tools and data sources.

In this tutorial, we’ll build a lightweight SQLite MCP server in Python using FastMCP. The server will expose the database schema as a readable resource and provide controlled, read-only tools for querying inventory data.

The goal isn’t simply to connect an AI model to a database. We also want to place clear security boundaries around what the model can do.

What We’ll Build

Our setup will look like this:

AI Application
      |
      v
   MCP Client
      |
      v
 SQLite MCP Server
      |
      +----------------+
      |                |
      v                v
 Schema Resource    Query Tools
      |                |
      +--------+-------+
               |
               v
        SQLite Database

The sample database will contain product information such as SKUs, categories, stock levels, and prices.

What You Need

Before starting, make sure you have:

  • Python 3.10 or newer
  • FastMCP
  • SQLite
  • Basic familiarity with Python and SQL
  • An MCP-compatible client for testing

SQLite is included with Python, so you don’t need to install a separate database server.

Why Security Matters

Giving an AI agent database access is different from simply giving it text to read.

If a database tool is poorly designed, the model could potentially request sensitive information or attempt operations that the application never intended to allow.

For a read-only assistant, a safer design is to:

  • Give the agent only the access it needs.
  • Open the database in read-only mode.
  • Use parameterized queries for structured operations.
  • Limit the amount of data returned.
  • Keep write and destructive operations separate.

We’ll apply these principles throughout the tutorial.

Step 1: Create the Python Environment

Create a new project directory:

mkdir sqlite-mcp-server
cd sqlite-mcp-server

Create a virtual environment:

python3 -m venv .venv

On macOS or Linux, activate it with:

source .venv/bin/activate

On Windows:

.venv\Scripts\activate

Now install FastMCP:

pip install fastmcp

Verify the installation:

python -c "import fastmcp; print(fastmcp.__version__)"

Step 2: Create the SQLite Database

Create a file named seed_db.py.

Add the following code:

import sqlite3


def init_db():
    conn = sqlite3.connect("inventory.db")
    cursor = conn.cursor()

    cursor.execute("""
        CREATE TABLE IF NOT EXISTS products (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            sku TEXT UNIQUE NOT NULL,
            name TEXT NOT NULL,
            category TEXT NOT NULL,
            stock_level INTEGER NOT NULL,
            unit_price REAL NOT NULL
        )
    """)

    sample_products = [
        ("SKU-1001", "Developer Mechanical Keyboard", "Hardware", 45, 129.99),
        ("SKU-1002", "USB-C Dual 4K Dock", "Accessories", 18, 179.50),
        ("SKU-1003", "Ergonomic Mesh Chair", "Furniture", 8, 349.00),
        ("SKU-1004", "Noise-Cancelling Headphones", "Audio", 22, 199.95)
    ]

    cursor.executemany("""
        INSERT OR IGNORE INTO products
        (sku, name, category, stock_level, unit_price)
        VALUES (?, ?, ?, ?, ?)
    """, sample_products)

    conn.commit()
    conn.close()

    print("Database seeded successfully.")


if __name__ == "__main__":
    init_db()

Run it:

python seed_db.py

This creates an inventory.db SQLite database in your project.

Step 3: Create the FastMCP Server

Create another file named server.py.

Start with:

import sqlite3
import json
from pathlib import Path

from fastmcp import FastMCP


mcp = FastMCP("Inventory SQLite Service")

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "inventory.db"


def get_connection():
    """Return a read-only SQLite connection."""

    if not DB_PATH.exists():
        raise FileNotFoundError(
            f"Database file not found: {DB_PATH}"
        )

    conn = sqlite3.connect(
        f"file:{DB_PATH}?mode=ro",
        uri=True
    )

    conn.row_factory = sqlite3.Row

    return conn

There are two important decisions here.

First, the database location is calculated relative to server.py. This avoids problems when an MCP client starts the server from another working directory.

Second, the SQLite connection uses:

mode=ro

This opens the database in read-only mode.

Step 4: Expose the Database Schema

An MCP resource can provide readable context to an MCP client.

We can expose the SQLite schema so the client understands which tables and columns exist.

Add this to server.py:

@mcp.resource("schema://inventory")
def get_database_schema() -> str:
    """Return the SQLite schema."""

    conn = get_connection()
    cursor = conn.cursor()

    cursor.execute("""
        SELECT name, sql
        FROM sqlite_master
        WHERE type = 'table'
        AND name NOT LIKE 'sqlite_%'
    """)

    tables = cursor.fetchall()
    conn.close()

    schema_dump = []

    for table_name, create_sql in tables:
        schema_dump.append(
            f"-- Table: {table_name}\n{create_sql};\n"
        )

    return "\n".join(schema_dump)

The client can now retrieve information about the structure of the database without receiving unrestricted database access.

Step 5: Create a Read-Only Query Tool

Next, create a tool that accepts read-only SQL queries.

@mcp.tool()
def query_inventory(sql_query: str) -> str:
    """Execute a read-only SELECT query."""

    clean_query = sql_query.strip()

    if not clean_query.lower().startswith("select"):
        return json.dumps({
            "error": "Only SELECT queries are permitted."
        })

    forbidden_keywords = [
        "insert",
        "update",
        "delete",
        "drop",
        "alter",
        "attach",
        "detach",
        "replace"
    ]

    tokens = clean_query.lower().split()

    for forbidden in forbidden_keywords:
        if forbidden in tokens:
            return json.dumps({
                "error": f"{forbidden.upper()} is not permitted."
            })

    conn = get_connection()
    cursor = conn.cursor()

    try:
        cursor.execute(clean_query)

        rows = cursor.fetchmany(50)

        results = [dict(row) for row in rows]

        return json.dumps({
            "count": len(results),
            "rows": results
        }, indent=2)

    except sqlite3.Error as error:
        return json.dumps({
            "error": f"SQLite error: {error}"
        })

    finally:
        conn.close()

Notice that we're not relying on the keyword checks as the only security mechanism.

The underlying SQLite connection is also read-only.

That gives us multiple layers of protection.

Step 6: Create a Parameterized Tool

For common operations, a narrowly defined tool is preferable to allowing the model to construct arbitrary SQL.

For example, let's create a tool for finding low-stock products:

@mcp.tool()
def get_low_stock_products(threshold: int = 15) -> str:
    """Return products at or below a stock threshold."""

    conn = get_connection()
    cursor = conn.cursor()

    query = """
        SELECT
            sku,
            name,
            category,
            stock_level,
            unit_price
        FROM products
        WHERE stock_level <= ?
        ORDER BY stock_level ASC
    """

    try:
        cursor.execute(query, (threshold,))
        rows = cursor.fetchall()

        return json.dumps(
            [dict(row) for row in rows],
            indent=2
        )

    finally:
        conn.close()

The important part is:

WHERE stock_level <= ?

combined with:

cursor.execute(query, (threshold,))

The value is supplied separately rather than being inserted directly into the SQL string.

Why Narrow Tools Are Useful

Compare:

query_database(sql_query)

with:

get_low_stock_products(threshold)

The first gives the model considerable freedom over the SQL it generates.

The second gives it one clearly defined operation.

When possible, purpose-built tools are easier to validate, monitor, and restrict.

Step 7: Limit Query Results

Our general query tool uses:

cursor.fetchmany(50)

so a single query doesn't automatically return thousands of rows.

For larger applications, consider additional controls such as:

  • Pagination

  • SQL LIMIT

  • Maximum response sizes

  • Column allowlists

  • Query timeouts

  • Maximum result counts

This is especially important when database results are going directly into an AI model's context.

Step 8: Test the MCP Server

FastMCP provides development tooling for inspecting and testing your server.

Run:

fastmcp dev server.py

Verify that your server exposes:

query_inventory
get_low_stock_products
schema://inventory

Test the tools with simple inventory queries before connecting the server to a larger AI application.

Step 9: Connect It to an MCP Client

Once the server works locally, you can connect it to an MCP-compatible client.

A command-based configuration may look similar to:

{
  "mcpServers": {
    "inventory-database": {
      "command": "fastmcp",
      "args": [
        "run",
        "/absolute/path/to/server.py"
      ]
    }
  }
}

Replace:

/absolute/path/to/server.py

with the real path to your server.py file.

The exact configuration and file location depend on the MCP client you're using, so check that client's current documentation.

Common Errors and Troubleshooting

SQLite: Unable to Open Database File

You may encounter:

sqlite3.OperationalError: unable to open database file

One possible cause is the MCP client launching your server from a different working directory.

That's why this tutorial uses:

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "inventory.db"

instead of relying on:

Path("inventory.db")

Problems With STDIO

If your MCP server uses standard input/output transport, arbitrary print() debugging can interfere with communication.

For server debugging, use Python's logging facilities and direct diagnostic logs to standard error or another appropriate destination.

Write Queries Don't Work

That's intentional.

Our database connection uses:

mode=ro

because this tutorial is designed around read-only access.

If your application genuinely needs to modify data, don't simply expose unrestricted write SQL.

Create specific write tools with validation, authorization, logging, and appropriate user confirmation.

Security Best Practices

Use Read-Only Connections

If an AI agent only needs to retrieve information, don't give it write access.

Our SQLite connection uses:

file:inventory.db?mode=ro

This creates a database-level restriction rather than depending only on instructions given to the AI model.

Use Parameterized Queries

For model-controlled or user-controlled values, use SQL parameters:

cursor.execute(
    "SELECT * FROM products WHERE stock_level <= ?",
    (threshold,)
)

Avoid directly concatenating untrusted values into SQL.

Limit Returned Data

Don't allow a simple query to dump an entire large database into the model context.

Set sensible limits and use pagination when necessary.

Prefer Purpose-Built Tools

Instead of one powerful database tool, consider specific operations such as:

get_product(sku)

get_low_stock_products(threshold)

search_products(category)

get_inventory_summary()

Each tool can have its own validation rules and permissions.

Keep Sensitive Data Out of Model Context

If your database contains passwords, API keys, payment information, private customer information, or other sensitive data, don't assume the AI model needs access to it.

Restrict which tables and columns are exposed.

Log Important Tool Activity

For production environments, consider recording:

  • Which tool was called

  • When it was called

  • Whether it succeeded

  • Which client initiated the operation

  • Relevant non-sensitive parameters

Avoid unnecessarily storing secrets or sensitive data in logs.

Why AI Agent Database Security Is Different

A traditional application generally performs database operations through predefined application code.

An AI agent introduces another decision-making layer:

User Request
     |
     v
AI Model
     |
     v
Tool Selection
     |
     v
Tool Arguments
     |
     v
Database Operation

Because the model participates in deciding which tools to call, security shouldn't depend on the model always making the correct decision.

The tool implementation and database permissions should enforce the important restrictions independently.

Final Result

At this point, you've created an SQLite MCP server that:

  • Uses FastMCP.

  • Exposes the database schema as an MCP resource.

  • Provides controlled read-only database access.

  • Uses SQLite's read-only mode.

  • Uses parameterized SQL for structured lookups.

  • Limits returned records.

  • Can be tested before connecting it to an AI application.

From here, you can gradually add purpose-built tools as your application requires them.

Conclusion

Building an MCP server for SQLite with Python and FastMCP is relatively straightforward.

The more important question is how much database access your AI agent actually needs.

For a read-only database assistant, a sensible starting architecture combines:

Least Privilege
      +
Read-Only Database Access
      +
Parameterized Queries
      +
Input Validation
      +
Limited Results
      +
Logging

The key principle is simple: don't rely on the AI model itself to enforce your security boundaries.

Your tools, database permissions, and infrastructure should enforce them.

Sources & Documentation

FastMCP Documentation:
https://gofastmcp.com/

FastMCP GitHub Repository:
https://github.com/PrefectHQ/fastmcp

Model Context Protocol Documentation:
https://modelcontextprotocol.io/

Python SQLite Documentation:
https://docs.python.org/3/library/sqlite3.html

3/related/default