Adding Persistent Storage to an MCP Server
After creating a basic MCP server with FastMCP and exposing a few simple tools, as described in Building a Custom MCP Server with FastMCP, an interesting next step is introducing persistent storage. While tools such as a greeting function or a simple calculator demonstrate how functionality can be exposed through the Model Context Protocol (MCP), they do not maintain any state between requests.
One way to add persistence is by integrating a SQLite database and exposing database operations as MCP tools. Unlike in-memory data structures, a SQLite database preserves data across server restarts, allowing information to be stored and retrieved reliably.
SQLite is a lightweight, file-based database that requires no separate server installation, making it an excellent choice for small applications, prototypes, and local MCP servers.
Database Design
To demonstrate database integration, the MCP server implements a simple todo management system backed by SQLite.
The application uses a single `todos` table containing three fields:
- `id` – unique identifier for each todo item
- `task` – the todo description
- `completed` – flag indicating whether the task has been completed
The database is initialized automatically when the MCP server starts. If the database file does not yet exist, the required table is created automatically, allowing the application to run without any manual setup steps.
This approach keeps the example focused on MCP integration while introducing the fundamental concepts required for working with persistent data.
Exposing Database Operations as MCP Tools
Database functionality can be exposed through MCP tools using the `@mcp.tool()` decorator. Once registered, these tools become available to MCP-compatible clients and can be invoked through natural language requests.
The following tools are implemented:
| Tool | Description |
|---|---|
| add_todo | Creates a new todo item |
| list_todos | Returns all stored todos |
| get_todo | Retrieves a specific todo by ID |
| complete_todo | Marks a todo as completed |
| delete_todo | Removes a todo item |
| clear_completed | Deletes all completed todos |
Each tool performs the required database operation and returns a structured response that can be interpreted by the AI client.
From the perspective of the client application, these database-backed tools are no different from the simple tools introduced earlier. The underlying implementation may involve SQL queries and persistent storage, but MCP presents them through the same consistent interface.
Testing with Claude Desktop
Once the MCP server is connected to Claude Desktop, the database functionality can be exercised through natural language interactions.
Typical requests include:
- creating new tasks
- displaying all stored tasks
- retrieving individual todo items
- marking tasks as completed
- deleting completed entries
Claude automatically selects the appropriate MCP tools based on the available tool descriptions, executes the required operations, and presents the results in a conversational format.
One particularly interesting aspect of this process is observing how the model reasons about the available capabilities. Rather than relying on hardcoded workflows, the model inspects the registered tool definitions and determines which operation is required to satisfy a given request.
This highlights one of the key advantages of MCP. New functionality can be introduced simply by exposing additional tools, allowing AI clients to discover and use those capabilities dynamically without requiring custom integration logic for every workflow.
Checking the database, we can confirm that the data has been inserted into the persistence layer by Claude.
Results
Adding SQLite transforms the MCP server from a simple proof of concept into an application capable of storing and retrieving persistent data.
Todo items now survive server restarts, demonstrating how MCP tools can be connected to real-world data sources while remaining easy to use from an AI client. The same pattern can be extended beyond a todo application to integrate business data, configuration management, file storage, or external services.
Although the example remains intentionally small, it illustrates an important architectural concept: MCP provides a clean abstraction layer between AI assistants and application logic. By exposing database operations as tools, AI models can interact with persistent data through a standardized interface while the implementation details remain encapsulated within the server.
MCP Server Source Code
from fastmcp import FastMCP
import sqlite3
mcp = FastMCP("My MCP Server")
DB_PATH = r"C:\Data\prj\mcp-server\todos.db"
def get_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task TEXT NOT NULL,
completed INTEGER DEFAULT 0
)
""")
conn.commit()
conn.close()
@mcp.tool()
def add_todo(task: str) -> str:
"""Add a new todo."""
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO todos (task) VALUES (?)",
(task,)
)
conn.commit()
conn.close()
return f"Added todo: {task}"
@mcp.tool()
def list_todos() -> list[dict]:
"""List all todos."""
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT id, task, completed
FROM todos
ORDER BY id
""")
rows = cur.fetchall()
conn.close()
return [
{
"id": row["id"],
"task": row["task"],
"completed": bool(row["completed"])
}
for row in rows
]
@mcp.tool()
def get_todo(todo_id: int) -> dict:
"""Get a single todo by ID."""
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT id, task, completed
FROM todos
WHERE id = ?
""", (todo_id,))
row = cur.fetchone()
conn.close()
if not row:
return {"error": f"Todo {todo_id} not found"}
return {
"id": row["id"],
"task": row["task"],
"completed": bool(row["completed"])
}
@mcp.tool()
def complete_todo(todo_id: int) -> str:
"""Mark a todo as completed."""
conn = get_connection()
cur = conn.cursor()
cur.execute("""
UPDATE todos
SET completed = 1
WHERE id = ?
""", (todo_id,))
conn.commit()
if cur.rowcount == 0:
conn.close()
return f"Todo {todo_id} not found"
conn.close()
return f"Todo {todo_id} marked as completed"
@mcp.tool()
def delete_todo(todo_id: int) -> str:
"""Delete a todo."""
conn = get_connection()
cur = conn.cursor()
cur.execute("""
DELETE FROM todos
WHERE id = ?
""", (todo_id,))
conn.commit()
if cur.rowcount == 0:
conn.close()
return f"Todo {todo_id} not found"
conn.close()
return f"Todo {todo_id} deleted"
@mcp.tool()
def clear_completed() -> str:
"""Delete all completed todos."""
conn = get_connection()
cur = conn.cursor()
cur.execute("""
DELETE FROM todos
WHERE completed = 1
""")
deleted = cur.rowcount
conn.commit()
conn.close()
return f"Deleted {deleted} completed todos"
if __name__ == "__main__":
init_db()
mcp.run()