All lessons · 44
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.
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:
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 Python6# functions, and it turns them into MCP tools.7from mcp.server.fastmcp import FastMCP8 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 model21# 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] = body26 # Whatever you return is the only thing the model gets back.27 # A friendly message beats a raw data dump.28 return #98c379">"Saved: " + title29 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 it49# 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: strbecomes 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:
# 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 (loggingwith aFileHandler) 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
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.