Chapter 5: Your First Server
Most first MCP servers work in the Inspector and fail in a real client, and the reason is almost never the protocol. This chapter builds a small server that works in both, and shows why each habit matters. The rule it pays off: every failure the model can fix, the server reports as a tool error that names every problem.
This chapter uses Python and the official SDK’s MCPServer class (called FastMCP before mcp 2.0) because it keeps the example compact. The same design principles apply in TypeScript or another SDK. By the end you will have a working server with two tools, one resource, input validation, persistent storage, tool errors the model can act on, a test that runs without any client, and a tested connection to both the Inspector and a real host client. It is also the server the rest of Part II keeps: Chapter 6 refactors it and Chapter 7 takes it remote.
What We Are Building
We will build a notes server with:
- two tools:
create_noteandsearch_notes, - one resource: a summary view of current notes,
- input validation that returns useful errors,
- SQLite persistence so notes survive restarts,
- and structured logging to stderr.
The domain is deliberately boring. Notes are simple enough to let the interface design stand out. If you find yourself wanting to add more features, resist. The point of this chapter is a clean vertical slice, not a feature showcase.
Project Setup
Create a project, add the SDK, and keep the server in a single file to start:
uv init mcp-notes
cd mcp-notes
uv add "mcp[cli]"
That gives you a project environment with the MCP SDK (2.x) and its mcp command-line tool; uv run uses it without activation. The code below targets mcp 2.x; if you are following older material that imports mcp.server.fastmcp, that import only exists on mcp<2. If uv is not yet installed, see Chapter 4 for the golden-path setup.
The Starter Server
Create server.py:
import logging
import os
import sqlite3
from datetime import datetime, timezone
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
# ---------- logging ----------
# MCP stdio servers must keep stdout clean for JSON-RPC.
# All diagnostic output goes to stderr.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()], # stderr by default
)
log = logging.getLogger("mcp-notes")
# ---------- database ----------
DB_PATH = "notes.db"
def _get_db() -> sqlite3.Connection:
# A real server creates the table once at startup;
# doing it here keeps the file self-contained.
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute(
"""
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
tag TEXT NOT NULL DEFAULT 'general',
created_at TEXT NOT NULL
)
"""
)
conn.commit()
return conn
# ---------- server ----------
mcp = MCPServer("notes")
@mcp.tool()
def create_note(title: str, body: str, tag: str = "general") -> dict:
"""Create a note with a title, body, and optional tag."""
# --- validation ---
title = title.strip()
body = body.strip()
tag = tag.strip().lower()
errors = []
if not title:
errors.append("title must not be empty")
if not body:
errors.append("body must not be empty")
if len(title) > 200:
errors.append("title must be 200 characters or fewer")
if errors:
raise ToolError("Note not saved: " + "; ".join(errors))
# --- persistence ---
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
conn = _get_db()
cursor = conn.execute(
"INSERT INTO notes (title, body, tag, created_at) VALUES (?, ?, ?, ?)",
(title, body, tag, now),
)
conn.commit()
note_id = cursor.lastrowid
conn.close()
log.info("created note %s: %s", note_id, title)
return {
"note_id": note_id,
"message": f"Saved note '{title}' with tag '{tag}'",
}
@mcp.tool()
def search_notes(query: str, tag: str | None = None) -> dict:
"""Search note titles and bodies. Optionally filter by tag."""
query = query.strip()
if not query:
raise ToolError("query must not be empty")
q = f"%{query}%"
sql = (
"SELECT id, title, tag, created_at FROM notes "
"WHERE (title LIKE ? OR body LIKE ?) "
)
params: list = [q, q]
if tag:
sql += "AND tag = ? "
params.append(tag.strip().lower())
sql += "ORDER BY created_at DESC"
conn = _get_db()
rows = conn.execute(sql, params).fetchall()
conn.close()
items = [dict(row) for row in rows]
log.info("search '%s' matched %d notes", query, len(items))
return {"count": len(items), "items": items}
@mcp.resource("notes://summary")
def notes_summary() -> str:
"""A readable list of all notes, one per line."""
conn = _get_db()
rows = conn.execute(
"SELECT id, title, tag FROM notes ORDER BY id"
).fetchall()
conn.close()
if not rows:
return "No notes have been created yet."
return "\n".join(
f"{row['id']}. {row['title']} [{row['tag']}]" for row in rows
)
if __name__ == "__main__":
mcp.run(transport="stdio")
This is still a short file—about 100 lines—but it already demonstrates several habits that separate a throwaway demo from a server you can extend:
-
Logging goes to stderr. The
logging.StreamHandler()default writes to stderr, which keeps stdout reserved for JSON-RPC messages. Chapter 4 explains what a strayprint()does to a stdio server; this is the fix in place. -
Persistence is real. An in-memory list is fine for a five-minute demo but breaks the moment you restart the server or reconnect a client. SQLite costs one import and a few SQL statements. It also forces you to think about data shape early, which pays off when you add resources.
-
Validation failures come back as tool errors (
isError: true) with every problem listed. RaisingToolErrortells the SDK this failure was expected: the call returnsisError: truewith your message as its content, and the server logs it without a traceback. The flag tells the client the call failed; the messages tell the model how to fix its next attempt. Returning a dict with an"error"key instead would go out as a successful call, so a client shows a green check and an eval harness counts a pass. An empty search is different: it succeeded and found nothing, so it returnscount: 0, not an error. -
Parameters are flat and concrete. The model and client route better when they see
title,body, andtagthan when they see a singlepayloadobject with nested fields. Flat parameters also produce cleaner JSON Schema, which means better tool descriptions in the client’s tool listing.
Why This Shape Works
The two tools express distinct intents: create something, find something. They do not overlap, and neither one tries to be a swiss-army knife. A common beginner mistake is to build one manage_notes tool that takes an action parameter. That forces the model to know your internal API, and it produces a vague schema that gives the client nothing useful to display. Name tools after what the user wants to accomplish: the model wants to create a note, not INSERT INTO notes.
The resource gives the client a read-only view of the note set without forcing a tool call. That is the first important MCP design lesson: not every useful thing should be an action. If the data is contextual and read-only, expose it as a resource that the host (or the user, through a picker) can attach to the conversation. The host decides when that happens, so anything the model must be able to fetch on its own also needs a tool; here, search_notes is that tool.
The descriptions are short but specific. "Create a note with a title, body, and optional tag" tells the model exactly what parameters to fill and in what shape. Compare that with "Manage notes" or "Run note operation"—those descriptions leave the model guessing, and guessing means hallucinated parameters.
Running and Inspecting the Server
Start the Inspector and point it at your server in one command:
npx -y @modelcontextprotocol/inspector uv run server.py
This launches the Inspector’s web UI (typically at http://localhost:6274) and spawns your server as a child process over stdio. You do not need to run the server separately.
In the Inspector, work through this checklist:
Tools tab:
- Both
create_noteandsearch_notesappear. - Click each tool name. The input schema shows the parameter names, types, and which are required.
- Read the descriptions. If they are unclear or vague from the Inspector’s display, they will be unclear to the model.
- Call
create_notewith a real title and body. Verify the response includesnote_idand a confirmation message. - Call
create_notewith an empty title and an empty body. Verify the result is marked as an error and names both problems. Models will occasionally send empty or malformed inputs, and your server must answer them without crashing. - Call
search_noteswith the query matching your test note. Verify it returns.
Resources tab:
notes://summaryappears with its description.- Read the resource. Before any notes exist, it should say “No notes have been created yet.” After you create a note via the tool, read it again. The note should appear in the summary.
Notifications pane:
- Check for any errors or warnings your server logged during the above tests.
If anything looks wrong in the Inspector, fix it now. The Inspector shows you exactly what the client and model will see. It is a controlled environment—no model involved, no prompt variability, no network. If the server does not behave correctly here, it will not behave correctly anywhere else.
Connecting to a Host Client
Once the Inspector is clean, configure a real host client. Claude Desktop is a common local option. Open its configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Claude Desktop’s official setup guide covers macOS and Windows. On other platforms, use a client that documents its own config location.
Add your server:
{
"mcpServers": {
"notes": {
"command": "/absolute/path/to/uv",
"args": ["--directory", "/absolute/path/to/mcp-notes", "run", "server.py"]
}
}
}
uv --directory changes into the project directory before it runs anything, so it finds server.py, the project’s environment and the right place for notes.db without relying on the client to set a working directory. That is why this book uses it for every local server instead of a client-specific cwd setting.
Three rules that prevent most configuration failures:
-
Use absolute paths. The client does not inherit your shell’s
PATHor virtual environment./Users/you/.local/bin/uvworks.uvalone usually does not. Runwhich uvto find yours. -
Point
--directoryat the project. Without it,server.pywill not be found, andnotes.dbwill be created in an unexpected location. -
Close the client before editing the config. Some clients overwrite the configuration file on shutdown. If you edit while the client is running, your changes may be lost.
Restart the client after saving the config. The notes server should appear in the client’s server list or tool panel. If it does not, check the client’s log output (Claude Desktop writes logs to ~/Library/Logs/Claude/ on macOS) for startup errors.
Your first test prompts should be simple and explicit:
- “Create a note titled ‘sprint plan’ with body ‘draft Q2 milestones’.”
- “Search my notes for ‘milestones’.”
- “Show me the notes summary.”
Do not start by asking for complex multi-step behavior. First confirm that the client can see the server, the tools work, and the resource resolves. Then experiment.
The Development Workflow
With both the Inspector and a host client working, you have a two-tier development loop:
-
Edit code → test in Inspector. This is your fast loop. The Inspector does not involve a model, so tests are deterministic and fast. Use it for schema changes, validation logic, new tools, and error handling.
-
Test in host client. This is your integration loop. It involves a model, so results are non-deterministic. Use it to verify that descriptions guide the model correctly, that error responses are recoverable, and that the overall workflow feels right.
Most of your development time should be in the first loop, but the second is not optional. A tool that works perfectly in the Inspector can still fail in a real client because the model misreads the description, the client renders the response differently, or a neighbouring tool’s description wins the routing. Test with a model in the loop before declaring the server done.
mcp dev server.py launches your server under the Inspector, but it does not watch files; after an edit, restart it. If you want automatic restarts, wrap the command in a file watcher (for example watchfiles). For final testing before committing, restart cleanly, so you know the startup path works from scratch.
Test It Without a Client
Because tool handlers are plain functions, most of your test suite never needs MCP running. Add pytest (uv add --dev pytest) and create test_server.py:
import pytest
from mcp.server.mcpserver.exceptions import ToolError
import server
def test_create_note_lists_every_problem(tmp_path, monkeypatch):
monkeypatch.setattr(server, "DB_PATH", str(tmp_path / "notes.db"))
with pytest.raises(ToolError) as err:
server.create_note(title=" ", body="")
assert "title must not be empty" in str(err.value)
assert "body must not be empty" in str(err.value)
def test_empty_search_is_not_an_error(tmp_path, monkeypatch):
monkeypatch.setattr(server, "DB_PATH", str(tmp_path / "notes.db"))
assert server.search_notes(query="milestones") == {"count": 0, "items": []}
uv run pytest runs both in well under a second. The two tests pin down this chapter’s rule from both sides: every problem is reported as an error, and an empty result is not one. Chapter 12 builds the rest of the testing story on this.
Adding One Layer of Realism
The server above still has rough edges that would bite you in a real deployment. Here are three improvements worth making before you move on. Each one teaches a lesson that applies to every MCP server you will build.
Constrain Unbounded Results
The current search_notes tool returns every match. With ten notes that is fine. With ten thousand, the response will blow past the model’s context window and either get truncated or degrade response quality. Because the query is already built up piece by piece, the change is small:
@mcp.tool()
-def search_notes(query: str, tag: str | None = None) -> dict:
- """Search note titles and bodies. Optionally filter by tag."""
+def search_notes(query: str, tag: str | None = None, limit: int = 20) -> dict:
+ """Search note titles and bodies. Optionally filter by tag. Returns up to `limit` results (max 100)."""
query = query.strip()
if not query:
raise ToolError("query must not be empty")
+ limit = max(1, min(limit, 100))
...
- sql += "ORDER BY created_at DESC"
+ sql += "ORDER BY created_at DESC LIMIT ?"
+ params.append(limit)
Out-of-range limits are clamped rather than rejected: a model asking for 500 results wants “a lot”, and 100 serves that intent better than an error round-trip. The description states the ceiling, so the clamp is not a surprise. The model does not need to know about the limit unless it wants to override it.
Add a Resource Template
The static notes://summary resource works, but MCP also supports resource templates—URIs with parameters that the client can fill in. Add one that lets the client read a single note by ID:
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
@mcp.resource("notes://detail/{note_id}")
def note_detail(note_id: int) -> str:
"""The full content of a single note."""
conn = _get_db()
row = conn.execute(
"SELECT id, title, body, tag, created_at FROM notes WHERE id = ?",
(note_id,),
).fetchone()
conn.close()
if not row:
raise ResourceNotFoundError(f"No note found with id {note_id}.")
return (
f"Title: {row['title']}\n"
f"Tag: {row['tag']}\n"
f"Created: {row['created_at']}\n\n"
f"{row['body']}"
)
This uses MCPServer’s URI template syntax. When the client requests notes://detail/3, the SDK extracts note_id=3 and passes it to the function. A missing note raises ResourceNotFoundError, which the client receives as an “invalid params” protocol error rather than a note whose body happens to say it is missing.
The client (or the user, via a picker) can attach a specific note to the conversation without a tool call. Whether the model can request it on its own depends on the host. Chapter 6 covers who controls what, and what to add when the model has to fetch a note by itself.
Separate Configuration from Code
The DB_PATH is currently hardcoded. That works for a tutorial, but in any real deployment you want the database location to be configurable without editing source code. Move it to an environment variable:
import os
DB_PATH = os.environ.get("NOTES_DB_PATH", "notes.db")
Then update your client configuration to set it:
{
"mcpServers": {
"notes": {
"command": "/absolute/path/to/uv",
"args": ["--directory", "/absolute/path/to/mcp-notes", "run", "server.py"],
"env": {
"NOTES_DB_PATH": "/absolute/path/to/notes.db"
}
}
}
}
The same server can now run in development with a local SQLite file and in Chapter 7 behind HTTP with the database somewhere else, without any code change.
Failure Modes
Server crashes on first tool call. Usually a missing dependency or an import error that only triggers when the tool function executes. The server starts fine (no tools are called during startup), but fails on the first real request. Fix: call every tool in the Inspector before connecting a client.
Client shows server but no tools. The server is running, but the tool list is empty. This happens when the server definition has a syntax error in a decorator or when mcp.run() is never reached because an earlier import fails silently. Fix: check the client’s logs for stderr output from the server process.
Notes database is created in the wrong directory. Without --directory in the client configuration, SQLite creates the file relative to wherever the client spawned the process, which is often the client’s own installation directory. Fix: always pass --directory, or set an absolute NOTES_DB_PATH.
Search returns no results despite matching notes existing. SQLite LIKE is case-insensitive for ASCII but not for Unicode. If your notes contain accented characters or non-Latin scripts and your queries use different casing, LIKE will miss them. Fix: normalize case before storage and before querying.
Tool call succeeds but model ignores the response. The model called the tool, got a valid response, but then produced an answer that contradicts the response. It happens more often when the response is very large (the model loses track in a long context) or when the response format is unusual. Fix: keep responses concise and structured. Return the minimum the model needs to compose its answer.
Inspector works, client does not. The Inspector launched the server from your shell, which has the right PATH and working directory; the client launched it with neither. Fix: absolute paths for everything in the client config, as above.
Implementation Checklist
Before moving to the next chapter, confirm:
- Server starts cleanly.
uv run server.pyproduces no errors and no stdout output. - Both tools appear in the Inspector with clear names, descriptions, and parameter schemas.
- Invalid input returns
isError: truelisting every problem; an empty search returnscount: 0. -
uv run pytestpasses without any client or Inspector running. -
search_notesis bounded. Alimitof 500 returns at most 100 rows. -
notes://summaryandnotes://detail/{note_id}resolve in the Inspector. - SQLite persistence works. Stop and restart the server. Previously created notes are still there.
- A real model can use the tools. At least one create and one search completed through the host client.
What Comes Next
Every choice in this server — two tools not one, a resource instead of a list_notes tool, flat parameters, errors that name every problem — was a design decision. Chapter 6 makes those decisions explicit, and then breaks some of them on purpose. The server’s final form, after Chapters 6 and 7, is Appendix C.