The FastMCP tutorials so far returned pure, stateless results. Real tools usually need state that persists — a record that is still there after the process restarts. In this tutorial you will build a FastMCP task-manager server whose tools read and write a SQLite database, so tasks survive restarts, redeploys, and reboots.
You will separate the persistence layer (db.py) from the MCP tools
(server.py), expose a live-stats resource, cover it with pytest
(including a test that proves data survives a restart), and deploy it as a
launchd service. The stack is Mac-native: uv, make, and Python’s built-in
sqlite3 — no database server to install.
What you will build
- A
db.pypersistence module oversqlite3: schema init and CRUD for tasks. - Tools:
add_task,list_tasks,complete_task,delete_task, with input validation surfaced asToolError. - A
tasks://statsresource returning live counts. - A
pytestsuite that isolates the database per test and verifies persistence. - A
launchddeployment whose database path is stable across redeploys.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Basic SQL familiarity.
sqlite3ships with Python, so there is nothing else to install.
Everything runs through uv and make.
Step 1: Scaffold and lock down hygiene
Create the workspace and a .gitignore first. It ignores the database files —
they are runtime state, not source.
Create the files
mkdir -p sqlite-fastmcp-server-macos
cd sqlite-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Runtime / deploy artifacts
logs/
*.log
*.pid
# SQLite database files (runtime state, not source)
*.db
*.db-journal
*.db-wal
*.db-shm
# OS / editor noise
.DS_Store
Detailed breakdown
*.dband the-journal/-wal/-shmvariants are SQLite’s data and its write-ahead/journal sidecar files. They hold live state and must never be committed — the schema in code recreates an empty database on first run.- The rest is standard
uv/macOS hygiene.
Step 2: Initialize the project with uv
Create the uv project and add FastMCP plus the test tools. sqlite3 is part of
the standard library, so it is not a dependency.
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A stateful FastMCP server backed by SQLite"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpis the only runtime dependency. Persistence uses the standard library, which keeps the deployment tiny and dependency-free.
Step 3: Write the persistence layer
Keep all database access in one module. Every operation opens a short-lived connection and commits — SQLite opens in microseconds, and this keeps the code thread-safe under an async HTTP server without a shared, cross-thread connection. The database path comes from an environment variable so tests and deployments can isolate it.
Create the file
touch db.py
Add the code: db.py
"""SQLite persistence for the tasks server.
State lives in a real database file, so tasks survive restarts and redeploys.
Every operation opens a short-lived connection (SQLite is fast to open and this
keeps the code thread-safe under an async HTTP server). The database path comes
from the DB_PATH environment variable so tests and deployments can point
somewhere isolated.
"""
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_DB = "tasks.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
"""
def _path() -> Path:
return Path(os.environ.get("DB_PATH", DEFAULT_DB))
@contextmanager
def _connect():
conn = sqlite3.connect(_path())
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
yield conn
conn.commit()
finally:
conn.close()
def init() -> None:
"""Create the schema if it does not exist (safe to call repeatedly)."""
with _connect() as conn:
conn.executescript(SCHEMA)
def _row_to_task(row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"title": row["title"],
"notes": row["notes"],
"done": bool(row["done"]),
"created_at": row["created_at"],
}
def add_task(title: str, notes: str = "") -> dict:
now = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cur = conn.execute(
"INSERT INTO tasks (title, notes, created_at) VALUES (?, ?, ?)",
(title, notes, now),
)
return get_task(cur.lastrowid, conn)
def get_task(task_id: int, conn: sqlite3.Connection | None = None) -> dict | None:
if conn is not None:
row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
return _row_to_task(row) if row else None
with _connect() as own:
row = own.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
return _row_to_task(row) if row else None
def list_tasks(status: str = "all") -> list[dict]:
query = "SELECT * FROM tasks"
params: tuple = ()
if status == "open":
query += " WHERE done = 0"
elif status == "done":
query += " WHERE done = 1"
query += " ORDER BY id"
with _connect() as conn:
return [_row_to_task(r) for r in conn.execute(query, params).fetchall()]
def complete_task(task_id: int) -> dict | None:
with _connect() as conn:
conn.execute("UPDATE tasks SET done = 1 WHERE id = ?", (task_id,))
return get_task(task_id, conn)
def delete_task(task_id: int) -> bool:
with _connect() as conn:
cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
return cur.rowcount > 0
def stats() -> dict:
with _connect() as conn:
row = conn.execute(
"SELECT COUNT(*) AS total, "
"COALESCE(SUM(done), 0) AS done FROM tasks"
).fetchone()
total, done = row["total"], row["done"]
return {"total": total, "done": done, "open": total - done}
Detailed breakdown
_connectis a context manager that opens a connection, setsrow_factory = sqlite3.Row(so rows behave like dicts), commits on success, and always closes. Opening per operation avoids sharing a connection across async request threads — the simplest correct model.initruns the schema withCREATE TABLE IF NOT EXISTS, so it is safe to call on every startup. This is your lightweight migration story; for evolving schemas you would add versioned migration scripts here.- Every query is parameterized (
?placeholders), which prevents SQL injection — never format user input into SQL strings. get_taskaccepts an optional connection soadd_task/complete_taskcan read back the row inside the same transaction, while callers can also use it standalone._row_to_taskconverts a row into a plain, JSON-serializable dict withdoneas a real bool — the shape the tools return to clients.
Step 4: Write the server
The tools are a thin, validated layer over db.py. db.init() runs at import so
the schema exists before the first request. A resource exposes live stats.
Create the file
touch server.py
Add the code: server.py
"""A stateful FastMCP server backed by SQLite.
The tools are a thin layer over `db.py`; all state lives in a SQLite database
file, so tasks persist across restarts and redeploys. A resource exposes live
stats. Errors (e.g. an unknown task id) are surfaced as ToolError.
"""
import os
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
import db
mcp = FastMCP("macmcp")
db.init() # ensure the schema exists before serving
@mcp.tool
def add_task(title: str, notes: str = "") -> dict:
"""Create a task and return it (including its new id)."""
if not title.strip():
raise ToolError("title must not be empty")
return db.add_task(title.strip(), notes)
@mcp.tool
def list_tasks(status: str = "all") -> list[dict]:
"""List tasks. status is one of: all, open, done."""
if status not in ("all", "open", "done"):
raise ToolError("status must be one of: all, open, done")
return db.list_tasks(status)
@mcp.tool
def complete_task(task_id: int) -> dict:
"""Mark a task as done and return it."""
task = db.complete_task(task_id)
if task is None:
raise ToolError(f"no task with id {task_id}")
return task
@mcp.tool
def delete_task(task_id: int) -> dict:
"""Delete a task by id."""
if not db.delete_task(task_id):
raise ToolError(f"no task with id {task_id}")
return {"deleted": task_id}
@mcp.resource("tasks://stats")
def task_stats() -> dict:
"""Live counts of total, open, and done tasks."""
return db.stats()
def main() -> None:
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio
if __name__ == "__main__":
main()
Detailed breakdown
db.init()at import guarantees the table exists before any tool runs, including the first request after a fresh deploy.- The tools validate then delegate.
add_taskrejects an empty title,list_tasksrejects an unknown status, andcomplete_task/delete_taskraiseToolErrorfor an unknown id.ToolErroris how FastMCP returns a clean, typed error to the client instead of a stack trace. tasks://statsis a resource, not a tool. Clients read it to see live counts without mutating anything — the natural fit for derived, read-only state.- The transport switch matches the other articles: stdio for local dev, HTTP for the deployed service.
Step 5: Test the layer, the tools, and persistence
Two test files. test_db.py drives the persistence layer directly; test_tools.py
drives the tools through the in-memory client. Both isolate the database under
pytest’s tmp_path, and one test proves state survives a “restart.”
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_db.py tests/test_tools.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
Add the code: tests/test_db.py
"""Test the SQLite persistence layer against an isolated database file."""
import importlib
import pytest
@pytest.fixture(autouse=True)
def isolated_db(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "tasks.db"))
import db
importlib.reload(db) # pick up the new DB_PATH cleanly
db.init()
return db
def test_add_and_get(isolated_db):
db = isolated_db
task = db.add_task("write article", "about sqlite")
assert task["id"] == 1
assert task["title"] == "write article"
assert task["done"] is False
assert db.get_task(1) == task
def test_list_filters_by_status(isolated_db):
db = isolated_db
db.add_task("a")
db.add_task("b")
db.complete_task(1)
assert [t["id"] for t in db.list_tasks("all")] == [1, 2]
assert [t["id"] for t in db.list_tasks("open")] == [2]
assert [t["id"] for t in db.list_tasks("done")] == [1]
def test_complete_and_delete(isolated_db):
db = isolated_db
db.add_task("a")
assert db.complete_task(1)["done"] is True
assert db.delete_task(1) is True
assert db.get_task(1) is None
assert db.delete_task(999) is False
def test_stats(isolated_db):
db = isolated_db
db.add_task("a")
db.add_task("b")
db.complete_task(1)
assert db.stats() == {"total": 2, "done": 1, "open": 1}
def test_state_persists_across_connections(isolated_db, tmp_path):
"""Data written by one connection is visible to a brand-new one."""
db = isolated_db
db.add_task("survives")
# A fresh reload simulates a server restart pointing at the same file.
import importlib
importlib.reload(db)
assert db.get_task(1)["title"] == "survives"
Add the code: tests/test_tools.py
"""Test the tools in-memory against an isolated database."""
import importlib
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
@pytest.fixture()
def mcp(tmp_path, monkeypatch):
monkeypatch.setenv("DB_PATH", str(tmp_path / "tasks.db"))
import db
import server
importlib.reload(db)
importlib.reload(server) # rebinds the tools to the reloaded db + fresh schema
return server.mcp
async def test_add_then_list(mcp):
async with Client(mcp) as client:
created = (await client.call_tool("add_task", {"title": "ship it"})).data
assert created["id"] == 1
listed = (await client.call_tool("list_tasks", {})).data
assert [t["title"] for t in listed] == ["ship it"]
async def test_complete_updates_stats_resource(mcp):
async with Client(mcp) as client:
await client.call_tool("add_task", {"title": "a"})
await client.call_tool("add_task", {"title": "b"})
await client.call_tool("complete_task", {"task_id": 1})
stats = (await client.read_resource("tasks://stats"))[0].text
assert '"done": 1' in stats and '"open": 1' in stats
async def test_delete(mcp):
async with Client(mcp) as client:
await client.call_tool("add_task", {"title": "temp"})
result = (await client.call_tool("delete_task", {"task_id": 1})).data
assert result == {"deleted": 1}
async def test_unknown_id_raises(mcp):
async with Client(mcp) as client:
with pytest.raises(ToolError):
await client.call_tool("complete_task", {"task_id": 999})
async def test_empty_title_rejected(mcp):
async with Client(mcp) as client:
with pytest.raises(ToolError):
await client.call_tool("add_task", {"title": " "})
Detailed breakdown
isolated_db/mcpfixtures setDB_PATHto a temp file andimportlib.reloadthe modules so each test starts from a fresh, empty schema and never touches your realtasks.db. Reloadingserverrebinds its tools to the reloadeddb.test_state_persists_across_connectionsis the point of the whole article: it writes a task, reloads the module (a stand-in for a process restart pointing at the same file), and confirms the task is still there — persistence, proven in a unit test.test_tools.pycovers the client-visible contract: CRUD round-trips, the stats resource updating after a mutation, andToolErrorfor bad input and unknown ids.
Run them:
uv run pytest -v
You should see all tests pass.
Step 6: The Makefile
Wrap development, the database, and the launchd deployment. Plain make prints
help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# --- Deploy configuration (launchd user agent) --------------------------------
LABEL := com.macmcp.tasks
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
DB := tasks.db
.PHONY: help install serve test demo reset-db deploy undeploy status logs clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
serve: ## Run the HTTP server in the foreground (Ctrl-C to stop)
MCP_TRANSPORT=http uv run python server.py
test: ## Run the test suite
uv run pytest -v
demo: ## Add sample tasks against a running server and show stats
uv run python scripts/demo.py
reset-db: ## Delete the database file (wipes all tasks)
rm -f $(DB) $(DB)-journal $(DB)-wal $(DB)-shm
@echo "Removed $(DB)."
deploy: ## Render the launchd plist and start the service
@mkdir -p logs
@sed -e 's#@UV@#$(UV)#g' \
-e 's#@DIR@#$(DIR)#g' \
-e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
deploy/$(LABEL).plist.template > $(PLIST)
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
launchctl bootstrap $(DOMAIN) $(PLIST)
@echo "Deployed $(LABEL). Check: make status"
undeploy: ## Stop the service and remove the launchd plist
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
rm -f $(PLIST)
@echo "Removed $(LABEL)."
status: ## Show whether the service is registered and running
@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."
logs: ## Tail the service log files
@tail -n 40 -f logs/server.out.log logs/server.err.log
clean: ## Remove caches and logs (keeps the database)
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
reset-dbdeletes the database (and its journal/WAL sidecars) when you want a clean slate.cleandeliberately keeps the database — cleaning caches should never wipe your data.deploybakes an absoluteDB_PATHinto the plist (Step 7), so the service always reads the same file regardless of its working directory. This is what makes state stable across redeploys.demoexercises the tools against a running server. The launchd lifecycle matches the companion deploy article.
Step 7: Deploy as a launchd service
The plist runs the server over HTTP with an absolute DB_PATH.
Create the files
mkdir -p deploy scripts
touch deploy/com.macmcp.tasks.plist.template scripts/demo.py
Add the code: deploy/com.macmcp.tasks.plist.template
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.macmcp.tasks</string>
<key>ProgramArguments</key>
<array>
<string>@UV@</string>
<string>run</string>
<string>python</string>
<string>server.py</string>
</array>
<key>WorkingDirectory</key>
<string>@DIR@</string>
<key>EnvironmentVariables</key>
<dict>
<key>MCP_TRANSPORT</key>
<string>http</string>
<key>MCP_HOST</key>
<string>127.0.0.1</string>
<key>MCP_PORT</key>
<string>8000</string>
<key>DB_PATH</key>
<string>@DIR@/tasks.db</string>
<key>PATH</key>
<string>@PATH@</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>@DIR@/logs/server.out.log</string>
<key>StandardErrorPath</key>
<string>@DIR@/logs/server.err.log</string>
</dict>
</plist>
Add the code: scripts/demo.py
"""Live demo: add a few tasks, complete one, and print the stats resource."""
import asyncio
import os
from fastmcp import Client
URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
async def main() -> None:
async with Client(URL) as client:
for title in ("write article", "review PR", "ship release"):
created = (await client.call_tool("add_task", {"title": title})).data
print("added:", created["id"], created["title"])
await client.call_tool("complete_task", {"task_id": 1})
stats = (await client.read_resource("tasks://stats"))[0].text
print("stats:", stats)
open_tasks = (await client.call_tool("list_tasks", {"status": "open"})).data
print("open :", [t["title"] for t in open_tasks])
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
DB_PATH=@DIR@/tasks.dbis the key line: an absolute path means the supervised service reads and writes one stable database, so tasks added today are still there after a redeploy or a reboot (RunAtLoad+KeepAlive).demo.pyadds a few tasks, completes one, and readstasks://stats— a quick way to see the server working over HTTP.
Deploy and try it:
make deploy
make status # state = running, a live pid
make demo
# added: 1 write article
# added: 2 review PR
# added: 3 ship release
# stats: {"total": 3, "done": 1, "open": 2}
# open : ['review PR', 'ship release']
Now prove persistence: redeploy (which restarts the process) and the tasks are still there.
make deploy
# ...then list tasks via a client — all three are still present, id 1 still done.
make undeploy
Troubleshooting
- Tasks vanish after a restart. The service is not reading the file you
think. Confirm the plist’s
DB_PATHis an absolute path (it is, viamake deploy), and that you did not runmake reset-db. database is lockedunder load. SQLite serializes writers. For a write-heavy server, enable WAL mode (PRAGMA journal_mode=WAL) ininit(), or move to a client/server database. For typical MCP tool traffic the default is fine.- A tool raises
no task with id N. That id does not exist (or was deleted). Calllist_tasksto see valid ids. - Tests interfere with each other or with
tasks.db. Each test setsDB_PATHto a temp file and reloads the modules; if you add tests, keep theisolated_db/mcpfixture so nothing writes the real database. - Schema changes don’t take effect.
CREATE TABLE IF NOT EXISTSwon’t alter an existing table. Add a migration (anALTER TABLE, versioned) ininit(), ormake reset-dbin development.
Recap
You built a stateful FastMCP server whose data lives in SQLite:
db.pyisolates persistence: schema init plus parameterized CRUD, with a short-lived connection per operation.server.pyexposes validated tools and a livetasks://statsresource, raisingToolErroron bad input.- The tests isolate the database per test and prove state survives a restart.
launchd+ an absoluteDB_PATHkeep the data stable across redeploys and reboots, all driven byuvandmake.
Next improvements
- Enable WAL mode and add indexes as the table grows.
- Add versioned migrations in
init()for schema evolution. - Add a
search_tasks(query)tool using SQLiteLIKEor FTS5 full-text search. - Swap SQLite for Postgres (via
psycopg) if you need concurrent writers or multiple server instances sharing state.