learn-mcp-with-phoebe / Builder session 2 of 8
Learn MCP with Phoebe · Builder track · Session 2 of 8

Your first MCP server

Last session you read the protocol on the wire. Tonight you write the thing that speaks it. In one sitting you will seed the Daybreak database, stand up a FastMCP server, expose query_sales as a real tool, and poke it in the MCP Inspector until it returns the March dip: $150.00. By the end you have three working tools and the one craft skill that separates a good server from a useless one - writing tool descriptions the model can actually act on.

🟢 Easy Practitioners: DE · DS · AI engineers · builders Python · spec 2025-11-25 45 min
0-3 · Welcome 3-16 · Setup + first tool 16-42 · Build + Inspector 42-45 · Q&A
Part 0

From reader to author

In b1 you watched tools/list and tools/call fly past on the wire. You never wrote a line of JSON to make that happen - and tonight you still will not. The Python SDK's FastMCP generates every one of those messages for you from ordinary Python functions. Your job is to write good functions and good docstrings; the SDK handles the protocol.

Live - presented in session Self-study - read after class ▶ Live MCP wire - step through real messages Official sources covered
★ What you walk out with today A running daybreak-mcp stdio server with three real tools over a seeded SQLite database, the MCP Inspector open on it, and the single most transferable server skill there is: writing the docstring and type hints that become the tool's name, description, and input schema - the interface the model reads to decide what to call.
Part 1 · covers official "Build an MCP server" setup, Academy "Project setup"

Setup that actually works 6 min live

Three commands and a seed script. We use uv - the fast Python package manager the official quickstart standardizes on - and the mcp[cli] package, which ships both the SDK and the mcp command line tool that runs the Inspector. Run these in order; do not skip the seed step, or every tool returns empty.

bash · create the projectuv init daybreak-mcp cd daybreak-mcp uv venv source .venv/bin/activate
bash · install the SDK + CLIuv add "mcp[cli]" # pip equivalent, if you are not on uv: # pip install "mcp[cli]"
bash · seed the running database# copy seed_daybreak.py + server.py from the project/ folder into this dir python seed_daybreak.py # -> daybreak.db created at .../daybreak.db (33 orders)
daybreak-mcp/ - everything the server needs, one folder server.py FastMCP app - tools live here seed_daybreak.py run once to build the db daybreak.db SQLite - 33 orders, 8 products pyproject.toml uv init wrote this; mcp[cli] added .venv/ the activated environment server.py opens daybreak.db by a path relative to itself, so run from anywhere - but keep the two files together.
🔍 Click to zoom - the whole project is four files
LiveWhy uv, and what the seed script builds3 min▶

uv creates the project, the virtual environment, and resolves dependencies in seconds - the official Python quickstart uses it, so your setup matches the docs exactly. The mcp[cli] extra is what gives you the mcp dev command in Part 3.

  • seed_daybreak.py drops any old daybreak.db and rebuilds it: customers, products, orders, order_items, subscriptions, events - the same schema as the SQL course, so the March dip is real data, not a fixture.
  • The number to remember: completed revenue for 2026-03 is $150.00 with one refund, against $271.00 in 2026-02. Your tool will report exactly that in Part 3.
  • Run the seed once. Re-run it any time to reset; it is idempotent.
If a tool returns "No orders found" You skipped the seed, or you are running the server from a directory where daybreak.db does not sit next to server.py. Re-run python seed_daybreak.py and confirm the file exists.
Part 2 · covers official "Build an MCP server" tools, DLAI "Creating an MCP Server", Academy "Defining tools"

FastMCP and your first tool 7 min live

A FastMCP server is a Python object you decorate functions onto. Instantiate it with a name, decorate a function with @mcp.tool(), and the SDK reads the function's name, its docstring, and its type hints to build the tool's name, description, and inputSchema - the exact three fields you saw in tools/list last session. You write Python; the wire format is derived.

python · server.py, top of fileimport pathlib import sqlite3 from mcp.server.fastmcp import FastMCP DB_PATH = pathlib.Path(__file__).parent / "daybreak.db" mcp = FastMCP("daybreak-mcp") def _query(sql: str, params: tuple = ()) -> list[tuple]: con = sqlite3.connect(DB_PATH) try: return con.execute(sql, params).fetchall() finally: con.close()
Your Python @mcp.tool() def query_sales(month: str) -> str: """Order count, refunds, and revenue for one month.""" ... sql ... return f"..." The tools/list entry it generates name "query_sales" description "Order count, refunds..." inputSchema { month: string, required } fn name docstring type hints No JSON Schema by hand. The type hint month: str becomes a required string param; add a default and it becomes optional. This is why the docstring matters: it is not a comment, it is the description the model reads to decide when to call.
🔍 Click to zoom - decorator to wire: one function, three protocol fields
Livequery_sales, line by line4 min▶

Here is the real tool from the running project - not a toy. Read it top to bottom: the decorator registers it, the signature declares the interface, the docstring documents it for the model, and the body just queries SQLite and returns a string. Every MCP tool result in this course is a string.

python · the first real tool@mcp.tool() def query_sales(month: str) -> str: """Order count, refunds, and completed revenue for one month (YYYY-MM).""" rows = _query( """ SELECT o.status, COUNT(DISTINCT o.order_id), COALESCE(SUM(oi.quantity * oi.unit_price), 0) FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE substr(o.order_date, 1, 7) = ? GROUP BY o.status """, (month,), ) if not rows: return f"No orders found for {month}." by_status = {status: (n, rev) for status, n, rev in rows} total = sum(n for n, _ in by_status.values()) completed_rev = by_status.get("completed", (0, 0.0))[1] refunded = by_status.get("refunded", (0, 0.0))[0] return ( f"{month}: {total} orders ({refunded} refunded), " f"completed revenue ${completed_rev:.2f}." )
  • month: str - one required string argument. The model must supply it; the Inspector will show a required field.
  • The docstring spells out the format (YYYY-MM). That single parenthetical stops the model guessing "March" instead of "2026-03".
  • Refunds are excluded from revenue by grouping on status - the March refund is why completed revenue is $150.00, not more.
LiveWriting tool descriptions the model uses well4 min▶

This is the craft point of the whole session. The docstring is not documentation garnish - it is the interface the model reads before every call. Watch the discovery message again and notice the description is the only prose the model gets:

Four rules that make descriptions the model acts on correctly:

  • State the format in the description, not just the type. (YYYY-MM) beats "a month string" every time.
  • Say what it returns and what it excludes. "completed revenue" tells the model refunds are already handled - it will not double-correct.
  • One tool, one job. A tool that both queries and mutates confuses the model about when it is safe to call.
  • Name the function like the action. query_sales reads as a verb; the function name becomes the tool name.
Real world

The tool nobody could call. A team shipped a get_data tool with the docstring "Returns data." The model never picked it correctly because it could not tell what data or when. Renaming it query_sales and writing one sentence about the month format and the revenue definition fixed the call rate overnight - no model change, just the description.

Part 3 · covers official Inspector docs, DLAI Inspector segment, Academy "Server inspector"

Run it and poke it: the Inspector 6 min live

One command runs your server and opens a browser devtool wired to it - the MCP Inspector. It is the single most useful thing in your toolkit this track: it lists your tools, lets you call them with arguments, and shows the raw result, so you never guess whether a bug is in your code or on the wire.

bash · launch the Inspectoruv run mcp dev server.py # opens the Inspector in your browser, connected over stdio # tabs: Tools · Resources · Prompts · and a live message log

Run uv run mcp dev server.py. The Inspector opens and connects; if it does not, the server threw on import - read the terminal.

Open the Tools tab. You should see query_sales with its description and a month field. That is your docstring and type hint, rendered.

Type 2026-03 into month and press call. Read the result string: it should say completed revenue $150.00.

Call it again with 2026-02 - $271.00. You just proved the March dip through your own server.

LiveInspector traffic vs the wire scenarios3 min▶

The stepper above is the same JSON-RPC the Inspector generates when you click "call". The Inspector is just a host that never involves a model - you play the model, choosing the tool and filling the arguments by hand. That is exactly why it is the right debugging tool: it removes the model's judgement so you can test the server in isolation.

  • Tools tab = the tools/list response, rendered as a form.
  • Calling a tool = one tools/call request and its result - the log tab shows the raw JSON.
  • A red result with isError:true = your tool ran and raised; a protocol error = the Inspector could not even reach the tool.
Self-studyCommon first-server failures3 min read▶

Three failures catch nearly everyone once. Recognize them and you save the afternoon:

  • Path. The server cannot find daybreak.db because it was launched from a different directory. Fix: DB_PATH is built from __file__ in the project, so keep server.py and the db together.
  • Unseeded db. Tools return "No orders found." Fix: run python seed_daybreak.py.
  • Silent exceptions. A tool raises but you only see a generic error in the host. Fix: run in the Inspector, which surfaces the traceback - and never print to stdout in a stdio server, because stdout is the protocol channel. Use stderr or logging.
stdio means stdout is sacred A stdio server sends every protocol message over stdout. A stray print("debug") corrupts the JSON stream and the host disconnects. Log to stderr instead.
Demo 1 of 2

Build query_sales end to end ★ 12 min · everyone builds

Do this live, all together. Five moves from empty folder to the March dip on screen through a server you wrote.

Seed the db. python seed_daybreak.py - confirm "33 orders".

Write the tool. Paste the FastMCP header and query_sales from Part 2 into server.py. Add if __name__ == "__main__": mcp.run() at the bottom - mcp.run() defaults to stdio.

Open the Inspector. uv run mcp dev server.py. Confirm query_sales appears in the Tools tab.

Call month=2026-03. Read the result: 2026-03: ... completed revenue $150.00.

Compare to 2026-02 ($271.00). The dip is real, and it is your server saying so.

Demo 2 of 2

Add two more tools ★ 10 min · build

Two tools give the server real range: top_products takes a date range with defaults, and subscription_status takes no arguments at all. Notice how the signatures change what the Inspector renders.

python · finished code, paste and re-run the Inspector@mcp.tool() def top_products(from_date: str = "2026-01-01", to_date: str = "2026-12-31") -> str: """Best-selling products by quantity for a date range (YYYY-MM-DD).""" rows = _query( """ SELECT p.name, SUM(oi.quantity) AS qty FROM order_items oi JOIN orders o ON o.order_id = oi.order_id JOIN products p ON p.product_id = oi.product_id WHERE o.order_date BETWEEN ? AND ? AND o.status = 'completed' GROUP BY p.name ORDER BY qty DESC LIMIT 5 """, (from_date, to_date), ) if not rows: return "No completed sales in that range." lines = [f"{i + 1}. {name} - {qty} sold" for i, (name, qty) in enumerate(rows)] return "Top products " + from_date + " to " + to_date + ":\n" + "\n".join(lines) @mcp.tool() def subscription_status() -> str: """Active vs cancelled subscription counts, with monthly quantities.""" rows = _query( """ SELECT CASE WHEN cancel_date IS NULL THEN 'active' ELSE 'cancelled' END, COUNT(*), SUM(monthly_qty) FROM subscriptions GROUP BY 1 """ ) parts = [f"{status}: {n} subs ({qty} bags/month)" for status, n, qty in rows] return "Subscriptions - " + "; ".join(parts)

Paste both under query_sales, save, and restart the Inspector.

top_products shows two fields, both pre-filled with defaults - that is what the = "..." defaults do to the schema (optional, with a default).

subscription_status shows no fields and a call button - a zero-argument tool. Call it and read active vs cancelled.

Homework

Try it yourself - this week ◐ 25-35 min total

Source material

Official sources covered

This session teaches the working core of the official server-build quickstart and the server-authoring lessons across the major courses, on the actual running project. Certificates and videos stay on the official sites. This page covers:

✓modelcontextprotocol.io - "Build an MCP server" (Python)uv setup, FastMCP, @mcp.tool(), mcp.run stdio - taught on Daybreak instead of the weather sample
✓DeepLearning.AI "MCP" - "Creating an MCP Server"Server object, tool decorator, type-hint schemas - full working content
✓Anthropic Academy "Introduction to MCP" - "Hands-on with MCP Servers"Project setup, defining tools, the server Inspector
◐Hugging Face MCP course - Unit 1 server sectionServer concepts covered; HF's Gradio path stays official
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · With FastMCP, what does a tool function's docstring become?

FastMCP derives the tools/list entry from your function: name from the function name, description from the docstring, inputSchema from the type hints. The docstring is the interface, not a comment.

2 · Your stdio server disconnects the moment a tool runs. Likeliest cause?

In stdio transport, every protocol message travels over stdout. A stray print() corrupts the JSON stream and the host drops the connection. Log to stderr instead.

3 · What is the MCP Inspector for?

The Inspector is a host that lets you play the model: pick a tool, fill arguments, see the raw result. It isolates the server so you can tell a code bug from a protocol problem.

Builder session 2 cheat sheet · pin this

Setupuv init · uv venv · source .venv/bin/activate · uv add "mcp[cli]" (or pip install "mcp[cli]") · python seed_daybreak.py.
The server objectfrom mcp.server.fastmcp import FastMCP; mcp = FastMCP("daybreak-mcp").
A tool@mcp.tool() over a plain function. Name, description, schema come from fn name, docstring, type hints.
Results are stringsEvery Daybreak tool returns a str. query_sales, top_products, subscription_status.
Run itif __name__ == "__main__": mcp.run() - defaults to stdio. b6 switches to streamable-http.
Inspect ituv run mcp dev server.py - browser devtool: Tools tab, call with args, read raw result.
The craft pointThe docstring is the interface the model reads. State format, say what is returned + excluded.
The number2026-03 completed revenue $150.00 (1 refund) vs 2026-02 $271.00 - the March dip.