All lessons · 44
Path 3 · AI Engineer Roadmap·Lesson 25 of 33

Build Your First MCP Server

A step-by-step MCP server in Python with the official SDK: tools, resources, prompts, stdio transport, and wiring it into a client.

LevelIntermediateTime~20 min

Decide what to expose first

An MCP server can offer three kinds of capabilities, and the choice matters more than the code:

  • Tools — actions the model decides to call: search, write, send, create. Use them for anything with side effects or computation.
  • Resources — data the app reads and injects into context: configs, schemas, documents. Use them for reference material.
  • Prompts — reusable templates the server ships, useful when your team has a standard way to do something (code review, incident analysis).

A good first server does one small thing well. We will build a notes server with two tools: save a note and list notes.

A “server” here is not a rack in a data center. It is a small program that sits quietly, answers questions like “what tools do you have?” and “run this tool with these arguments,” and gives the answer back. That is all.

To follow along you need three things: Python 3.10 or newer, a terminal, and one install command.

How much effort is this, honestly? The code below is about forty lines and you can have it running in ten minutes. Budget an hour for the first one anyway, and here is where that hour actually goes: getting the client’s config file to point at the right absolute path, restarting the app so it notices the change, and learning to debug a program whose output you cannot see (a stdio server’s print goes straight into the protocol stream and breaks it). The code is not the hard part. The wiring is. If your first server does not show up in the client, that is the normal experience, not a sign you did something wrong.

The minimal server

The official Python SDK (pip install "mcp[cli]") gives you FastMCP, where a tool is just a decorated Python function. Type hints become the JSON Schema, and the docstring becomes the description the model reads:

notes server + client config
1# One-time setup, run this in your terminal:
2# pip install "mcp[cli]"
3# It installs the official MCP Python SDK.
4 
5# FastMCP is the simple way to write a server: you write normal Python
6# functions, and it turns them into MCP tools.
7from mcp.server.fastmcp import FastMCP
8 
9# Every server has a name. The client shows it to the user.
10mcp = FastMCP(#98c379">"notes")
11 
12# Our "database" is just a Python dictionary: note title -> note text.
13# It lives in memory, so notes disappear when the server stops.
14_notes: dict[str, str] = {}
15 
16 
17# @mcp.tool() registers this function as a tool the model can call.
18# The docstring below is what the MODEL reads - write it for the model,
19# not for a human reviewer.
20# The type hints (title: str, body: str) become the JSON Schema the model
21# uses to build a correct call.
22@mcp.tool()
23def save_note(title: str, body: str) -> str:
24 #98c379">"""Save a note under the given title. Overwrites if the title exists."""
25 _notes[title] = body
26 # Whatever you return is the only thing the model gets back.
27 # A friendly message beats a raw data dump.
28 return #98c379">"Saved: " + title
29 
30 
31@mcp.tool()
32def list_notes() -> str:
33 #98c379">"""List all saved note titles."""
34 if not _notes:
35 # A useful error message lets the model recover on its own.
36 return #98c379">"No notes yet."
37 return "\n".join(sorted(_notes))
38 
39 
40# A resource is reference material the APP can read into context -
41# the model does not decide to call this, the app does.
42@mcp.resource(#98c379">"notes://help")
43def help_text() -> str:
44 #98c379">"""Short guide to the notes server."""
45 return #98c379">"Ask the model to save or list notes by title."
46 
47 
48# stdio transport: the client starts this script itself and talks to it
49# through standard input and output. No network port is opened anywhere.
50if __name__ == #98c379">"__main__":
51 mcp.run(transport=#98c379">"stdio")

Three things to notice:

  • The docstring is the interface. The model never sees your code — only the function name, type hints and docstring. Write the docstring for the model, not for a human reviewer.
  • Type hints are load-bearing. title: str, body: str becomes the JSON Schema the model uses to build a correct tool call.
  • stdio transport means the client launches your script and talks to it over standard input and output. Nothing listens on a network port.

Test it before you touch a client

Do this step. It is the difference between “the server is broken” and “the client cannot find my file”, which feel identical otherwise. The SDK ships a debugger that starts your server and lets you call the tools by hand, in a browser:

Run the server in the MCP Inspector
# install the official SDK (once)
pip install "mcp[cli]"

# start your server under the Inspector, in a browser
mcp dev server.py

# ...or call it straight from the terminal, no client involved
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python server.py

# If you see your two tools listed here, the server works.
# If not, the bug is in the code - not in the client config.

Two things that break every first server, and neither is obvious:

  • Print debugging breaks it. With stdio transport, print() writes into the very stream the protocol uses, so a stray print corrupts the conversation. Log to a file (logging with a FileHandler) or to stderr instead.
  • Relative paths do not work. The client launches your script from an arbitrary working directory. Use an absolute path in the config, and build file paths relative to __file__, not to the current directory.

Register it in a client

The config file above is for Claude Desktop (Settings, then Developer, then Edit Config). The command plus args say: launch this script as a local process and connect to it over stdio. After you restart the app, the two tools appear in the model’s context automatically — ask it to “save a note called groceries with milk and bread” and watch the tool call happen.

Cursor, VS Code (Copilot agent mode), Zed and Claude Code use a similar JSON shape or the equivalent claude mcp add command. The protocol is the point: the same server works everywhere. Which is not to say it is always smooth: clients differ in where the config file lives, whether they need a full restart, and how loudly they tell you a server failed to start (often: not at all). Check the client’s own MCP docs first, then come back here for the server-side reasoning.

Local vs remote

So far the server runs on your machine, launched by the client (stdio). For a team you host it remotely over HTTP instead:

{
  "mcpServers": {
    "notes": {
      "url": "https://notes.example.com/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}

With a URL config the client connects over Streamable HTTP instead of launching a process. Be honest about the jump in effort: a remote server is a public endpoint, which means real auth (OAuth is part of the spec’s authorization story), TLS, rate limiting, logging, secrets management and someone to notice when it is down — the same list you would accept for any production API, not less. A practical rule: start with stdio while developing, move to HTTP when more than one person or service needs the server, and count that move as a real project rather than a config change.

Best practices that actually matter

  • Clear tool descriptions. Say what the tool does, when to use it, and when not to use it. “Save a note. Use for user-created text; do not use for code snippets” beats “saves data”.
  • Small tool sets. Every tool’s schema occupies context, and too many similar tools confuse the model. Ten focused tools beat forty overlapping ones.
  • Helpful error messages. When a call fails, return a message the model can act on: “Note ‘groceries’ not found. Available: milk-list, ideas” — not a raw stack trace. Models recover from good errors without human help.
  • Return structured, compact results. Trim the payload to what the model needs; huge outputs burn context window.
  • Make tools safe by default. Confirm destructive operations, scope credentials narrowly, and never let a tool do more than its description promises.

Check yourself

Check yourself

What does the docstring of a tool function actually do in an MCP server?

What does stdio transport mean in practice?

You want to expose reference material the app reads into context — not something the model decides to call. Which capability do you use?

Where to go from here

You can now build, register and harden an MCP server — a genuinely marketable skill, since teams are wiring MCP into everything. If you skipped the theory, MCP: the USB port for AI explains the three roles and the request flow, and tools and function calling covers the mechanism MCP is built on.