Disclosure: this article was drafted with AI assistance and reviewed by the author.
Chapter 2 ended with an exercise: turn one of your server boundaries into a real server. This chapter does that — but not the server you eventually want. The first server has a different job: to make every concept from Chapter 2 concrete while the moving parts are still few enough to hold in your head.
So we will build the smallest useful server we can: it answers one question, “what does this HTTP status code mean?” It exposes exactly one tool, lookup_http_status, and one resource, a short reference of the five status-code categories. It is read-only, self-contained, and deterministic. It makes no network calls, needs no credentials, and holds no secrets — which means there is nothing sensitive to leak while you learn.
This choice is deliberate, and it previews a habit worth keeping. A server should do one job that you can state in one sentence. “Look up HTTP status codes” qualifies. “Look up HTTP status codes, and also check the weather, and also read local files” does not — that is three servers wearing one name, and Chapter 2 already showed why that boundary matters. If you cannot describe the server’s job in one sentence, split it.
What we are deliberately leaving out is as important as what we include. No authentication: the data is public reference material. No Streamable HTTP: stdio keeps the deployment to a single local process. No prompts, no elicitation, no subscriptions, no tasks. Each of those earns its place in a later chapter, once the basic exchange is second nature.
You need Python 3.10 or newer and a terminal. The official Python SDK is the mcp package; we will use its high-level interface, FastMCP, which handles the JSON-RPC framing, the server/discover response, and the dispatch of list and call operations. That leaves you free to concentrate on the capability surface, which is the part you own.
Create a project directory with a single file:
http-status-server/ server.py
Then make a virtual environment and install the SDK:
python -m venv .venvsource .venv/bin/activatepip install "mcp[cli]"
The [cli] extra adds the command-line helpers used later in this chapter. One caution before you go further: SDKs for a young protocol move quickly. These examples follow the 1.x line of the mcp package. If an import path in your installed version differs from what you see here, check the SDK documentation for your version rather than guessing — the concepts are stable even when the spelling shifts.
Verify the install before you write any server code: run python -c “import mcp; print(mcp.version)” inside the virtual environment and confirm it prints a version. If it fails, the usual cause is installing into a different Python than the one you run — which python and which pip should point at the same .venv. Sorting this out now saves a confusing “module not found” later, when the error will surface inside a host process you cannot see into.
Open server.py. The first decisions are the server’s name and its instructions — the two things a host learns about you before anything else.
import loggingimport sys
python
from mcp.server.fastmcp import FastMCP
logging.basicConfig(stream=sys.stderr, level=logging.INFO)logger = logging.getLogger("http-status")
mcp = FastMCP( name="http-status", instructions="Look up the meaning of HTTP status codes from a built-in reference table.",)
A few things to notice. The name is lowercase, hyphenated, and free of spaces: hosts display it, and a clean name survives being shown in a tool picker. The instructions are one factual sentence. They are not a system prompt and not a place for persuasion; some hosts show them to the model, so state what the server does and nothing more.
Treat the instructions as documentation with an audience of two: the person reading the Inspector, and the model deciding whether your tool fits the user’s request. Both read it cold, with no other context about your intentions. That is why “Look up the meaning of HTTP status codes from a built-in reference table” works and “Helps with HTTP stuff” does not — the first lets a stranger predict what the server will and will not do. Like the tool descriptions coming later, review instructions as an interface change whenever you edit them.
The logging line deserves attention now because it prevents a failure you will otherwise meet later: it sends logs to standard error. Standard output belongs to the protocol, and we will return to that rule shortly.
When a client calls server/discover against this server, the SDK answers with the server’s identity — the name http-status, the instructions, the supported protocol versions — and its capabilities. Because we are about to register a tool and a resource, the capabilities will advertise exactly those two primitives and nothing else. Discovery is honest by construction: it describes what the server actually exposes.
A tool in FastMCP is a Python function with a decorator. The function name becomes the tool name, the docstring becomes the tool description, and the type annotations become the input schema:
STATUS_CODES = { 200: {"phrase": "OK", "category": "Success", "note": "The request succeeded."}, 301: {"phrase": "Moved Permanently", "category": "Redirection", "note": "The resource has a new permanent URL."}, 404: {"phrase": "Not Found", "category": "Client error", "note": "The server has no representation for the target."}, 500: {"phrase": "Internal Server Error", "category": "Server error", "note": "The server failed while processing the request."}, # ... one entry per standard code}
php
@mcp.tool()def lookup_http_status(code: int) -> str: """Look up the meaning of an HTTP status code.
Searches a built-in table of standard HTTP status codes; it makes no network calls. Returns the code’s category, standard phrase, and a one-line usage note. Read-only: it never modifies anything. Accepts integer codes from 100 to 599 and raises an error for anything outside that range. """ if not 100 <= code <= 599: raise ValueError(f"code must be between 100 and 599, got {code}") entry = STATUS_CODES.get(code) if entry is None: return f"{code} is not a standard HTTP status code." return f"{code} {entry['phrase']} ({entry['category']}): {entry['note']}"
A note on parameters you do not have yet: if a future version of this tool took an optional argument — say, verbose: bool = False — the default would make it optional in the input schema, while the required list would still contain only code. Defaults are a clean way to extend a tool without breaking existing callers, but each new parameter is a new promise in your public interface. For this first server, one required parameter is the whole contract, and that is a feature.
Chapter 2 said a tool description should name the data source, the required scope, the time behavior, the result limits, and whether the operation has side effects. Here is that checklist made concrete:
The input schema comes from the annotation code: int. Before your function ever runs, the SDK validates the incoming arguments against that schema: a missing code, or a value that is not an integer, is rejected at the boundary with an error result. Your own check then handles the domain rule the schema cannot express — that 99 and 600 are integers but not status codes — and raises ValueError with a message that names the bad value. The SDK turns that exception into a JSON-RPC error result, so the host learns the call failed and why.
Notice the two different treatments of failure. A code outside 100–599 is invalid input, so it raises. A code inside the range but absent from the table — say, 210 — is a valid query with a negative answer, so it returns a normal result saying so. The distinction matters: an error result means “the call did not do what you asked,” while a result that says “not a standard code” means “the call worked and the answer is no.” Hosts and models treat those differently, so do not blur them.
The tool takes an argument and computes an answer. The status-code categories need neither: they are a fixed reference with a stable identity. That makes them a resource, not a second tool.
@mcp.resource("http-status://categories")def status_categories() -> str: """The five HTTP status-code categories and the ranges they cover.""" return ( "1xx Informational (100-199): the request was received.\n" "2xx Success (200-299): the request was received and accepted.\n" "3xx Redirection (300-399): further action is needed.\n" "4xx Client error (400-499): the request was invalid.\n" "5xx Server error (500-599): the server failed to fulfill a valid request.\n" )
A client lists it with resources/list and reads it with resources/read, addressing it by its URI. Compare the two primitives side by side and the intent distinction from Chapter 2 becomes tangible: the resource is retrieved, the tool is performed. A useful test of the boundary: if the categories ever needed an argument — say, a language for the descriptions — the resource would stop being honest, and you would remodel it as a tool.
A stdio server is a program that speaks JSON-RPC on its standard input and output. Add the entry point at the bottom of server.py:
if __name__ == "__main__": mcp.run() # stdio transport by default
Run it:
python server.py
Nothing visible happens. That is correct: the process is waiting on stdin for a JSON-RPC request, and there is no one to talk to yet. Type a line and press enter and you will get a protocol error back — which, oddly, is a good sign. It means the server is listening.
This is also the moment to understand what an AI host does with your file. When the host starts, it spawns python server.py as a child process, connects pipes to its stdin and stdout, and speaks JSON-RPC through those pipes for as long as the host runs. There is no port, no URL, no daemon to manage. The server’s lifetime is the host’s decision: it starts the process when it needs the capability and kills it when it is done. That simplicity is why stdio is the right first transport — deployment is a file path, and the failure modes are process failure modes you already know.
Talking to the server by hand-typing JSON-RPC gets old after one request. The MCP Inspector gives you a user interface over the same exchange:
npx @modelcontextprotocol/inspector python server.py
Open the address it prints. The Inspector connects over stdio, calls server/discover, and shows you the server’s identity and capabilities. From there you can list tools, inspect the input schema your annotations produced, and call lookup_http_status with different arguments while watching the raw requests and responses. Every later section of this chapter is easier if you keep the Inspector open beside your editor.
In the Inspector, work through three views in order. First, the server view: confirm the name reads http-status and the capabilities list tools and resources. If the name is wrong here, it is wrong everywhere downstream. Second, the tools view: read your own description and input schema as a stranger would, and fix anything that assumes knowledge the reader does not have. Third, the call view: run lookup_http_status with 200, then with 99, then with the argument missing entirely, and watch the three different outcomes — a result, an error result from your range check, and an error result from schema validation. Those three shapes are the whole vocabulary of tool invocation.
If the Inspector cannot start your server at all, the cause is almost always the launch command, not your code: the wrong Python, a relative path resolved from the wrong directory, or a virtual environment the Inspector’s shell does not see. Run the exact command yourself in a terminal first. The same advice applies when an AI host reports that the server failed to start — hosts usually surface the child process’s stderr, which is another reason that stream is where your logs belong.
To connect an AI host, you tell the host how to launch your server as a child process. The exact configuration shape varies by host; the common form looks like this:
{ "mcpServers": { "http-status": { "command": "python", "args": ["/path/to/http-status-server/server.py"] } }}
Use the full path to your file, and if you installed the SDK in a virtual environment, point command at that environment’s Python. Restart the host, and the tool appears in its catalog. Ask the host “what does a 503 mean?” and watch what happens: the model selects lookup_http_status, the host validates the arguments and sends tools/call, and the answer comes back through the same path you just exercised by hand.
Whether the client is the Inspector or an AI host, the exchange follows the same shape. Walk through it once deliberately; this is the protocol from Chapter 2 with the abstractions removed.
The client calls server/discover and the server describes itself: the name http-status, the supported protocol versions, and the capabilities — tools and resources. The client uses the versions to select one both sides support, and every later request carries that version in its _meta field. The server answers from the request alone; it remembers nothing between calls.
The client calls tools/list and receives the tool definition exactly as the host and model will see it:
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "lookup_http_status", "description": "Look up the meaning of an HTTP status code. ...", "inputSchema": { "type": "object", "properties": { "code": { "type": "integer", "title": "Code" } }, "required": ["code"] } } ] }}
Read this response as a reviewer, not as the author. The description and the schema are your public interface — the only things a stranger’s host knows about your tool. If the description is vague here, it is vague everywhere.
The client sends tools/call with the tool name and arguments:
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "lookup_http_status", "arguments": { "code": 404 } }, "_meta": { "protocolVersion": "2026-07-28" }}
The server validates the arguments, runs the function, and returns the result:
{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "404 Not Found (Client error): the server has no representation for the target." } ] }}
Call it with { "code": 99 }. The schema accepts the integer, your range check rejects it, and the client receives an error result naming the invalid input instead of a result pretending everything worked:
{ "jsonrpc": "2.0", "id": 4, "error": { "code": -32602, "message": "code must be between 100 and 599, got 99" }}
(The exact numeric error code is the SDK’s choice; what matters is the shape — an error result, correlated by id, with a message a human can act on.)
Sooner or later — probably today — something in this setup will not work. Diagnose in this order, because each layer is cheaper to check than the next:
This order mirrors the debugging lens from Chapter 2: transport, envelope, protocol match, domain operation. The habit of checking layers from the outside in will still be paying off when your servers have authentication, retries, and production traffic.
Return to the logging line from the start of the chapter, because it is the easiest rule in this book to violate by accident. Over stdio, every byte your process writes to standard output is parsed as protocol. A single print() left over from debugging — a “got here!” or a dumped dictionary — corrupts message framing, and the failure surfaces far from its cause as a cryptic JSON parse error in the host. You will blame the SDK, the host, and the protocol before you find the print statement. Everyone does this once.
The fix is a habit, not a trick: configure logging to standard error once at startup, and never write to standard output yourself. Log what helps you diagnose the server — the tool name, the argument keys, timing, the outcome — and leave the values out when they could carry anything sensitive. Even in this toy server, “log shapes, not values” is worth practicing, because the habit transfers to servers that handle credentials.
Note what we are not using: the MCP protocol logging primitive. It is deprecated as of the 2026–07–28 revision. For a local stdio server, stderr is the right channel; in production, structured telemetry belongs in OpenTelemetry, as Chapter 2 described.
These are not hypothetical. They are the failures that appear in every beginner’s server, including the author’s, roughly in this order.
1.The everything server. The lookup tool works, so you add a weather tool “while you’re at it,” then a file reader, then a shell wrapper. Each addition feels small; together they recreate the unscoped capability surface Chapter 2 warned about, and now one process holds every permission the host granted. The fix is the narrow rule from the start of this chapter: one server, one job. When a second job appears, start a second server.
2.Debugging with print(). Covered above, but it belongs on the list because it is the most common way a working server breaks. The fix: logging to stderr from the first line of the project, and a personal ban on print() in server code.
3.Logging secrets. A server that handles API keys or tokens will, sooner or later, log its full arguments to debug a failure — and the secret lands in a log file, a terminal scrollback, or a pasted bug report. The fix: log argument names and shapes, never values, and treat any log line as potentially public.
4.Swallowing errors. The broad except Exception that returns “something went wrong” feels safe; it is the opposite. The host cannot distinguish a transient failure from a bug, the model cannot recover, and you cannot diagnose. The fix: raise specific errors with messages that name the bad input and the rule it broke, and let the SDK turn them into error results.
5.Never testing the real input schema. You tested with {"code": 404} and it worked. The model will send {"code": "404"}, or {"code": 404, "verbose": true}, or omit the argument entirely. The fix: open the Inspector and call your tool the way a stranger would — wrong types, extra fields, missing fields — and decide your coercion and rejection policy deliberately instead of discovering it in production.
One more, for readers following older tutorials: if a guide tells you to use sampling — server-initiated model completions — the guide predates the 2026–07–28 revision. Sampling is deprecated. A server that needs model inference integrates directly with a language-model provider under its own controls.
You should now be able to build and run a small MCP server without treating the SDK as magic:
Pick the server you sketched in Chapter 2’s exercise and shrink it to a first version with one tool and at most one resource. Then answer:
Keep the answers. In Chapter 4, you will put this server’s tool through the full design checklist — naming, descriptions, schemas, and side-effect discipline.
Error result: A JSON-RPC response carrying an error instead of a result, correlated to its request by id; how a server reports a failed call.
FastMCP: The high-level Python interface in the official MCP SDK that maps decorated functions to tools and resources.
Input schema: The JSON Schema describing a tool’s accepted arguments, derived here from Python type annotations and enforced by the SDK before the tool function runs.
MCP Inspector: A developer tool that connects to an MCP server and lets you exercise discovery, listing, and calls by hand.
Read-only tool: A tool that retrieves or computes information without modifying anything; declaring this in the description helps hosts decide when approval is unnecessary.
Version note: This chapter targets MCP specification revision 2026–07–28. That revision is stateless by default, uses server/discover for server version and capability discovery, and deprecates sampling and the protocol logging primitive. Check the current specification and your SDK’s documentation before implementing version-sensitive behavior.
Next: Chapter 4 — Designing Tools Well: names, descriptions, and schemas that models can actually use. Follow to get it when it drops.
Your First MCP Server was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.