Build a Local Notes MCP Server in Python: SQLite Tools + MCP Inspector (2026)
Your local AI assistant can explain code, but it cannot automatically know what your team decided last week unless you give it a safe, useful way to retrieve that information. The Model Context Protocol (MCP) gives you a standard interface for exposing small, well-defined tools and data to compatible AI applications. You do not need to build a complete agent platform to try it.
In this guide, you will build a local project-notes MCP server in Python. It stores notes in SQLite, exposes tools for adding and searching notes, publishes a read-only resource containing recent notes, and can be tested with the official MCP Inspector before you connect it to an AI host. The example stays on your machine and does not call a hosted model or send note contents to a third-party API.
Last checked: October 25, 2026. The commands follow the current MCP Python SDK v2 documentation. Package versions and host configuration formats can change, so consult the official documentation linked below before upgrading an existing project.
Who this tutorial is for
- Python developers who want to build their first custom MCP server.
- Developers who use a compatible desktop AI application or coding agent and want to expose a small local data source.
- Teams that want a simple prototype for searchable project notes, decisions, runbooks, or development context.
You should know basic Python, functions, and SQL. You do not need to train a model, install Ollama, or run a vector database for this example.
What you will build
The finished server exposes three capabilities:
- Tool — add_note: stores a note with a title, body, and optional tags. This is a write operation, so a compatible host may ask for approval before calling it.
- Tool — search_notes: searches the title, body, and tags using a parameterized SQLite query.
- Resource — notes://recent: returns a small, read-only snapshot of recent notes for a host to load as context.
MCP separates the host (the AI application), its MCP client, and the server you write. Your server does not call the language model directly. It advertises tools and resources through the protocol; the host decides when and how to use them. For the official explanation, read the MCP Python SDK first-steps guide.
1. Install the prerequisites
The current v2 documentation for the official Python SDK requires Python 3.11 or newer. The MCP Inspector also requires Node.js 22.19.0 or newer. You can use another MCP-compatible host later, but the Inspector is a convenient way to test the protocol without involving a model.
Install uv if you do not already have it. Then create the project and install the SDK plus its development command-line tools:
uv init local-notes-mcp
cd local-notes-mcp
uv add "mcp[cli]"
Check the runtime versions before continuing:
python --version
uv --version
node --version
If your system Python is older than 3.11, configure uv to use a supported interpreter rather than forcing an incompatible SDK install. The official SDK installation guide explains the current package and CLI extra.
2. Create the local SQLite-backed server
Create a file named server.py in the project directory. The implementation below uses Python's built-in sqlite3 module, so no separate database server or ORM is required.
import json
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from mcp.server import MCPServer
DB_PATH = Path(
os.environ.get("NOTES_DB_PATH", "./data/notes.db")
).expanduser().resolve()
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
@contextmanager
def db_session():
connection = sqlite3.connect(DB_PATH, timeout=5)
connection.row_factory = sqlite3.Row
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def initialize_db():
with db_session() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)
"""
)
initialize_db()
mcp = MCPServer("local-project-notes")
@mcp.tool()
def add_note(title: str, body: str, tags: str = "") -> dict[str, int | str]:
"""Save a project note with a title, body, and optional comma-separated tags."""
title = title.strip()
body = body.strip()
tags = tags.strip()
if not title or len(title) > 120:
raise ValueError("Title must contain 1 to 120 characters.")
if not body or len(body) > 12000:
raise ValueError("Body must contain 1 to 12000 characters.")
if len(tags) > 300:
raise ValueError("Tags must be 300 characters or fewer.")
created_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
with db_session() as connection:
cursor = connection.execute(
"INSERT INTO notes (title, body, tags, created_at) VALUES (?, ?, ?, ?)",
(title, body, tags, created_at),
)
note_id = cursor.lastrowid
return {"id": int(note_id), "title": title, "created_at": created_at}
@mcp.tool()
def search_notes(query: str, limit: int = 10) -> list[dict[str, int | str]]:
"""Search note titles, bodies, and tags. Returns at most 25 matching notes."""
query = query.strip()
if not query:
raise ValueError("Provide a non-empty search query.")
if len(query) > 100:
raise ValueError("Search query must be 100 characters or fewer.")
limit = max(1, min(limit, 25))
pattern = f"%{query}%"
with db_session() as connection:
rows = connection.execute(
"""
SELECT id, title, body, tags, created_at
FROM notes
WHERE title LIKE ? OR body LIKE ? OR tags LIKE ?
ORDER BY created_at DESC
LIMIT ?
""",
(pattern, pattern, pattern, limit),
).fetchall()
return [dict(row) for row in rows]
@mcp.resource("notes://recent")
def recent_notes() -> str:
"""Read a compact snapshot of the ten most recent project notes."""
with db_session() as connection:
rows = connection.execute(
"""
SELECT id, title, body, tags, created_at
FROM notes
ORDER BY created_at DESC
LIMIT 10
"""
).fetchall()
return json.dumps([dict(row) for row in rows], ensure_ascii=False, indent=2)
The SDK derives the tool names, argument schemas, and descriptions from the decorated Python functions, their type hints, and docstrings. The SQLite queries use placeholders instead of string-concatenating user input into SQL. That is important even for a local tool: tool arguments may be suggested by a model and should be treated as untrusted input.
The code initializes the database when the server module is loaded. The default database path is ./data/notes.db, relative to the directory from which you launch the process. You can set NOTES_DB_PATH to use a different location.
3. Run the server in development mode
From the project directory, run:
uv run mcp dev server.py
The SDK's development command starts the server and opens the MCP Inspector. If the browser does not open automatically, use the local URL printed by the command. The Inspector is the official interactive tool for listing and calling MCP capabilities; its documentation describes the browser, CLI, and terminal interfaces.
On first launch, the project may spend a moment resolving dependencies. The command should then display the Inspector URL and server connection status. This is a development workflow, not a production deployment command.
4. Test the add-note tool
In the Inspector's Tools view, select add_note. Try this sample input:
- title: Redis fallback decision
- body: Keep the first release on a single provider adapter. Add a second provider only after the error and timeout metrics are available.
- tags: architecture, api, decision
Call the tool. A successful result should include a numeric note ID, the saved title, and a UTC timestamp. The exact presentation can vary with the Inspector version. If you receive a validation error, try a shorter title and a non-empty body.
Next, call search_notes with query set to provider and limit set to 10. The saved note should appear in the results. Search is intentionally simple substring matching; it is not semantic search and it will not understand synonyms.
5. Read the resource
In the Inspector, open the Resources view and read notes://recent. It should return a JSON array containing the latest ten notes, including the one you just added. This gives an MCP host a read-only snapshot it can use as context.
Tools and resources serve different purposes. A tool is an action the model may request; a resource is data the application chooses to load. The server does not guarantee that every host automatically reads the resource. Host behavior and approval settings differ, so test the integration in the actual application you intend to use.
6. Understand the data and search behavior
SQLite stores the notes in one local file. You can inspect it with a SQLite command-line client if one is installed:
sqlite3 data/notes.db "SELECT id, title, tags, created_at FROM notes ORDER BY id DESC LIMIT 10;"
If the sqlite3 command is unavailable, that does not mean the Python server is broken; Python's standard-library SQLite driver can still create and query the database. The official Python sqlite3 documentation covers connections, transactions, placeholders, and row handling.
For a small personal notes store, substring search is often enough. For thousands of long notes, consider SQLite FTS5 or a dedicated search index. For semantic retrieval, you would need an embedding model and vector search; that is a separate design decision and is not included in this example.
7. Connect it to a compatible AI host
Once the server works in the Inspector, follow the configuration instructions for your chosen MCP host. Most local desktop hosts that support stdio-based MCP servers need a command and arguments that launch the server inside this project's environment. A typical command shape is:
uv --directory /absolute/path/to/local-notes-mcp run mcp run server.py
Replace the path with the real absolute path on your machine, and confirm the host's current configuration format in its official documentation. Some hosts expect a JSON configuration file, while others provide an MCP server settings screen. Do not copy a configuration example from another application without checking its current schema.
Keep the first integration local and limited to the notes directory. Start by asking the host to search for the Redis decision, then verify that the returned text matches the note you saved. If you enable the write tool, check how the host handles confirmation for tool calls that modify data.
8. Security and privacy checklist
- Do not store secrets in notes. The database is local, but any connected host may read and include note content in a model context.
- Keep SQL parameterized. Do not replace the placeholders with f-strings or concatenate model-provided values into SQL. See the OWASP SQL Injection Prevention Cheat Sheet.
- Keep the tool surface small. This server exposes specific operations; it does not accept arbitrary SQL or arbitrary filesystem paths.
- Limit input and output sizes. The code caps title, body, tags, query length, and result count so one request cannot return an unlimited number of notes.
- Protect the database file. Keep it out of version control, restrict OS file permissions, and back it up if the notes matter.
- Do not print to stdout in stdio mode. MCP protocol messages use stdout; diagnostic output can corrupt the stream. Use Python logging, which normally writes to stderr, instead.
- Review tool permissions. The add tool changes state. Use a host that shows tool calls and approvals, and do not grant broad write access unnecessarily.
This is a local prototype, not a multi-user service. It has no user authentication, per-user data isolation, encryption-at-rest layer, retention policy, or audit log. Do not expose it as a network service without designing those controls.
9. Troubleshooting
The Inspector command fails before the UI opens
Check node --version and confirm Node.js is at least 22.19.0 for the current Inspector line. Also check that uv is installed and the project dependencies resolve. Use the SDK's official installation page if package resolution fails.
The server appears to start but no tools are listed
Confirm that the file is named server.py, that the mcp = MCPServer(...) object exists at module scope, and that the decorators are imported from the installed SDK. Read the server startup logs for import errors. The current SDK v2 has breaking changes compared with v1, so avoid mixing v1 snippets with v2 packages.
SQLite reports a missing database or table
The database path is relative to the process's working directory unless you set NOTES_DB_PATH. Confirm which directory the host launches from. The code creates the parent directory and table when imported, but a different working directory may produce a second database file.
The search result is empty
First call add_note successfully, then search for a word that appears in the title, body, or tags. The current search is case-insensitive substring matching under SQLite's default behavior for ordinary ASCII text; it is not fuzzy or semantic search.
The host says the server exited or the connection closed
Run uv run mcp dev server.py again and inspect the initial error output. Do not add debug print() calls to stdout in a stdio server. Use logging to stderr or reproduce the failing tool call in the Inspector.
10. Trade-offs and next steps
| Choice | Why it helps | What it does not solve |
|---|---|---|
| SQLite | Simple, local, no separate database service | Not a substitute for a multi-user database with access controls |
| Parameterized substring search | Easy to inspect and harden against SQL injection | No semantic relevance ranking; wildcard matching is basic |
| MCP tools and resource | Standard way to expose data and actions to compatible hosts | Does not make the host or model automatically trustworthy |
| MCP Inspector | Tests the protocol and arguments without relying on model quality | Does not replace unit tests, authorization reviews, or production monitoring |
After the prototype works, add automated tests for empty inputs, long inputs, duplicate notes, and search edge cases. The official SDK supports in-memory testing with its Client class; see the SDK testing guide. If you need HTTP deployment, read the SDK's transport and authorization guides before opening a network port.
For related reading on GyanAangan, see AI developer automation with MCP, MCP servers in LM Studio Bionic, and building a local LLM gateway with LiteLLM.
FAQ
Does MCP require an AI model to run?
No. The server and protocol can be tested independently with the Inspector. A compatible host may later use an LLM to decide when to call a tool, but the server itself does not need to run a model.
Is this the same as an API endpoint?
It serves a similar integration purpose, but MCP standardizes how a host discovers and invokes tools, reads resources, and retrieves prompts. A regular REST API is still appropriate when you need a conventional web-service contract.
Can I use this with Ollama or LM Studio?
Only if your chosen application/version supports connecting to external MCP servers in the required transport and configuration format. Verify that capability in the host's official documentation; this tutorial does not assume a specific client integration works unchanged.
Is the notes database private by default?
The database is stored locally, but privacy depends on operating-system access, backups, the host you connect, and whether note contents are sent to a hosted model. Local storage alone does not guarantee that every part of the workflow remains offline.
Should I expose this server publicly?
Not as written. The example is intended for local development. A remote deployment needs authentication, authorization, network restrictions, input and output limits, logging, and a clear policy for which users can read or change each note.
Official references
- MCP Python SDK repository — current SDK code and examples.
- MCP Python SDK installation — Python requirements and CLI package.
- MCP Python SDK first steps — tools, resources, prompts, and server concepts.
- MCP Inspector documentation — current browser, CLI, and terminal testing options.
- Python sqlite3 documentation — database API and parameter binding.
- OWASP SQL Injection Prevention Cheat Sheet — safe query construction.
Bottom line: A useful MCP integration can start with a handful of bounded Python functions and a local database. Build the smallest capability you need, inspect every tool call, and add authentication and operational controls before expanding beyond your own machine.
