Tutorial

MCP Fundamentals: Build a Real MCP Server

You drove an MCP server with curl; now write one. Start with the stdio transport where the client literally launches your server as a subprocess, then grow a notes server with tools, error handling that language models can actually read, static and templated resources, and prompts. Flip the same server to Streamable HTTP and curl it again, then exercise it with three clients: raw curl, a real SDK client you write yourself, and the fastmcp CLI.

In the previous module you drove an MCP server with curl: you know the envelope, the session header, the three list methods, and why errors ride inside successful responses. Time to switch chairs. In this module you write the server - and, briefly, the client too - and you will find that MCP's server API is mostly ordinary functions plus metadata, with the protocol machinery generated around them.

The arc: start with the stdio transport (where the client launches your server as a subprocess - the dominant shape for local tools), grow a complete notes server with tools, resources, prompts and model-readable errors, then flip one flag and serve the same code over Streamable HTTP - the shape you already know from curl. Three clients will exercise your server along the way: an SDK client you write yourself, plain curl, and the fastmcp CLI.

Prerequisites

  • Module 1 - MCP is Just HTTP POSTs, or equivalent wire-level familiarity (initialize, tools/call, in-band isError).
  • Reading-level Python. We use type hints and docstrings, nothing exotic.
  • The lab starts both files pre-seeded in ~/mcp-lab: hello.py (a stdio server) and client.py (an MCP client).
Two transports side by side - stdio launches the server as a subprocess; Streamable HTTP runs it as a service the client connects to

Same protocol, two very different deployments. Every server you write in this module runs in both shapes.

Two transports, one protocol

MCP currently standardizes two transports, and they could not feel more different to a REST developer:

stdioStreamable HTTP
Server lifecyclethe client launches it as a subprocessindependent service, many clients
WireJSON-RPC lines over stdin/stdoutHTTP POSTs (you know this)
Discoveryimpossible not to find ita URL, like any API
Authno transport-level auth; trust depends on the host and process boundaryOAuth 2.1 (next module)
Typical hostClaude Desktop, VS Code, local CLIsChatGPT connectors, company gateways

The mental trap is to read that table as "local vs production". The real distinction is who controls the server's lifetime. A stdio server is an extension of one host application - it starts when the host needs it and dies with it. An HTTP server is a peer service - started by ops, shared by many callers. Same protocol messages in both.

Stdio is not inherently trusted just because it is local. The host still chooses which executable to launch, what arguments and environment it receives, and which user permissions it inherits; treat that configuration as part of the security boundary.

Hello, stdio

Look at the seeded ~/mcp-lab/hello.py. The entire server is this:

from fastmcp import FastMCP

mcp = FastMCP(name="hello-stdio")


@mcp.tool
def shout(text: str) -> str:
    """Return the text in upper case."""
    return text.upper()


if __name__ == "__main__":
    mcp.run(transport="stdio")

A decorated function. That is all a tool is. Three things are being generated for you from these five lines of definition:

  • the name (shout) and the JSON Schema for its arguments (from the type hints),
  • the description ("Return the text in upper case.") - lifted from the docstring, this is what the model reads when choosing between tools,
  • the wire handler that maps tools/call {"name": "shout", ...} to this function and its return value back to content.

Now the part with no REST analogue: run it through the seeded client, which launches the server itself:

cd ~/mcp-lab
uv run --with 'fastmcp==4.0.0' client.py
INFO     Starting MCP server 'hello-stdio' with transport 'stdio'
tools: ['shout']
result: HELLO MCP
structured: {'result': 'HELLO MCP'}

Read client.py in the editor - all fifteen lines of it. The interesting line:

transport = StdioTransport(
    command="uv",
    args=["run", "--with", "fastmcp==4.0.0", "python", "hello.py"],
)

The client spawns hello.py as a child process, performs the whole initialize handshake over its stdin/stdout - the same handshake you typed as curl in module 1 - and calls shout. The server's stderr (that INFO banner) passes straight through to your terminal: in stdio land, stderr is the server's log channel, because stdout is the protocol.

Note

The Inspector exists, just not here. The official GUI for poking MCP servers is npx @modelcontextprotocol/inspector - excellent tool, but it needs Node, which this playground deliberately skips. Everything the Inspector does, you will do with three tools that need no Node: curl, client.py, and the fastmcp CLI later in this module.

Type hints are the API contract

Watch what the framework does with those five lines. Ask the server for its own catalog (add this two-liner temporarily if you like; we will grow the real server next):

cd ~/mcp-lab
uv run --with 'fastmcp==4.0.0' python - <<'EOF'
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StdioTransport

async def main():
    transport = StdioTransport(command="uv",
                               args=["run", "--with", "fastmcp==4.0.0", "python", "hello.py"])
    async with Client(transport) as client:
        tool = (await client.list_tools())[0]
        import json
        print(json.dumps(tool.model_dump(exclude_none=True), indent=2))

asyncio.run(main())
EOF
{
  "name": "shout",
  "title": "Shout",
  "description": "Return the text in upper case.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "text": {"type": "string"}
    },
    "required": ["text"],
    "additionalProperties": false
  }
}

str became {"type": "string"} plus a required-ness constraint, the docstring became the description, and defaults would become default. This is the same JSON Schema you read from tools/list in module 1 - and it is the entire contract the model ever sees. A useful REST analogy is OpenAPI's job; MCP moves that contract from a sidecar file into the running endpoint, generated from the code itself.

Call it with wrong arguments and schema validation rejects the call before your function runs - the client surfaces it as a JSON-RPC -32602 invalid-params error, not a Python traceback.

Grow the server: tools with real behavior

Create ~/mcp-lab/notes.py - this is the file you will grow for the rest of the module, and the file the end-of-module tasks grade. Start with the storage helper and two tools:

"""notes - an MCP server over a JSON-lines notebook."""

import json
from pathlib import Path

from fastmcp import FastMCP

NOTES_FILE = Path(__file__).parent / "notes.jsonl"

mcp = FastMCP(name="notes")


def _load_notes() -> list[dict]:
    if not NOTES_FILE.exists():
        return []
    with NOTES_FILE.open() as f:
        return [json.loads(line) for line in f if line.strip()]


@mcp.tool
def add_note(text: str, tag: str = "general") -> dict:
    """Append a note to the shared notebook.

    Args:
        text: The note body.
        tag: Short label grouping related notes.
    """
    notes = _load_notes()
    note = {"id": len(notes) + 1, "text": text, "tag": tag}
    with NOTES_FILE.open("a") as f:
        f.write(json.dumps(note) + "\n")
    return {"saved": note["id"], "total": len(notes) + 1}


@mcp.tool
def note_stats() -> dict:
    """Count notes per tag in the notebook."""
    notes = _load_notes()
    per_tag: dict[str, int] = {}
    for n in notes:
        per_tag[n["tag"]] = per_tag.get(n["tag"], 0) + 1
    return {"total": len(notes), "per_tag": per_tag}


if __name__ == "__main__":
    mcp.run(transport="stdio")

Two details worth internalizing, because they are framework-idiomatic and not obvious:

  • The Args: block in the docstring feeds per-parameter descriptions into the schema - the difference between a model guessing what tag means and a model knowing.
  • Returning a dict gives you structuredContent for free; returning a str lands in content[0].text. Return what your callers are: code wants dicts, models are happy with either.

One more thing, inherited from hello.py: the entry point at the bottom. Without it, python notes.py defines mcp and exits - and a stdio client that spawns the file finds no protocol on the other end. Run it through a throwaway stdio client, the same shape as the seeded client.py pointed at your server instead:

cd ~/mcp-lab
uv run --with 'fastmcp==4.0.0' python - <<'EOF'
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StdioTransport

async def main():
    t = StdioTransport(command="uv",
                       args=["run", "--with", "fastmcp==4.0.0", "python", "notes.py"])
    async with Client(t) as c:
        r = await c.call_tool("add_note", {"text": "buy oat milk", "tag": "shopping"})
        print("add_note ->", r.structured_content)
        r = await c.call_tool("note_stats", {})
        print("note_stats ->", r.structured_content)

asyncio.run(main())
EOF
add_note -> {'saved': 1, 'total': 1}
note_stats -> {'total': 1, 'per_tag': {'shopping': 1}}
It hangs - or dies instantly. Which end is broken?

Stdio failures are almost always an entry-point mismatch between the file and how it is being launched:

  • No mcp.run(...) block at all -> the spawned process exits before the handshake -> the client fails with a connection-closed error.
  • mcp.run(transport="http") spawned by a stdio client -> the server starts happily on its port and never speaks a word of stdio -> the client waits forever. Symptom: your prompt never comes back and nothing prints. Ctrl+C, then make the entry point's transport match the launch style: stdio entry for clients that spawn the file, http entry for clients that dial a URL. (You will flip this same block to http in the "One flag to HTTP" section.)
  • A probe hung, you Ctrl+C'd it, and the next run dies with address already in use -> Ctrl+C reaped the client but not the server it spawned, and the stray is squatting on the port. pkill -f notes.py clears it; re-run.

Errors a model can act on

Add a lookup tool, and immediately face the design question module 1 previewed: what should happen when the note does not exist? First try the lazy way - return the sad message as a string:

@mcp.tool
def note_get(id: int) -> str:
    """Return one note by id.

    Args:
        id: The note number, counting from 1.
    """
    notes = _load_notes()
    if id < 1 or id > len(notes):
        return f"No note has id {id} - the notebook has {len(notes)} note{'s' if len(notes) != 1 else ''}."
    n = notes[id - 1]
    return f'#{n["id"]} [{n["tag"]}] {n["text"]}'

Nothing crashes - and that is the problem. Do not take my word for it; drive it over the protocol. Save the probe as a file - it runs twice, once against each version of the tool:

cd ~/mcp-lab
cat > err_probe.py <<'EOF'
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StdioTransport

async def main():
    t = StdioTransport(command="uv",
                       args=["run", "--with", "fastmcp==4.0.0", "python", "notes.py"])
    async with Client(t) as c:
        r = await c.call_tool("note_get", {"id": 99}, raise_on_error=False)
        print("structured ->", r.structured_content)
        print("text       ->", r.content[0].text)
        print("is_error   ->", r.is_error)

asyncio.run(main())
EOF
uv run --with 'fastmcp==4.0.0' python err_probe.py
structured -> {'result': 'No note has id 99 - the notebook has 1 note.'}
text       -> No note has id 99 - the notebook has 1 note.
is_error   -> False

(The count is whatever your notebook holds - the previous section's add_note leaves it at 1.)

The caller sees isError: false and a perfectly calm string - wrapped as {'result': ...} structured content, no less; a model may well read "No note has id 99" as data and cheerfully relay it. The protocol's answer is the in-band error flag, and the framework's way to set it is to raise:

from fastmcp.exceptions import ToolError


@mcp.tool
def note_get(id: int) -> str:
    """Return one note by id.

    Args:
        id: The note number, counting from 1.
    """
    notes = _load_notes()
    if id < 1 or id > len(notes):
        raise ToolError(f"No note has id {id} - the notebook has {len(notes)} note{'s' if len(notes) != 1 else ''}.")
    n = notes[id - 1]
    return f'#{n["id"]} [{n["tag"]}] {n["text"]}'

Replace, do not append: keeping both definitions makes fastmcp warn about a duplicate component at startup, and which one serves a call becomes a guessing game.

Re-run the probe unchanged - only the server changed:

uv run --with 'fastmcp==4.0.0' python err_probe.py
structured -> None
text       -> No note has id 99 - the notebook has 1 note.
is_error   -> True

Same message, opposite flag: the result carries no structured payload now, just the text and isError: true - the in-band error you read off the wire in module 1. Two details: with the client's default (raise_on_error=True) this same response arrives as a raised ToolError - the kwarg is what turns the flag into an inspectable result. And the message survived the raise: ToolError's text reaches the client verbatim, so write error strings for the model that will read them. (The server logs the failed call to its stderr.) Keep the ToolError import at the top of the file with the others.

The rule this encodes, and the reason it matters here more than in REST:

  • Expected failure (note missing, query invalid) → raise ToolErrorisError: true, HTTP 200. The model reads the message and adapts - exactly the in-band error design from module 1.
  • Unexpected failure (disk exploded) → let the exception fly → the SDK still wraps it as an error result; log it server-side and let the observability module's tooling catch it.

Resources: static and templated

Tools are the verbs; resources are the nouns. You already consumed notes://all in module 1 - now write it, plus its templated sibling, which is how a server exposes a family of addressable things. Replace the existing ToolError import, then add the resources:

from fastmcp.exceptions import ResourceError, ToolError


@mcp.resource(
    "notes://all",
    name="all_notes",
    description="Every note in the notebook, newest last.",
)
def all_notes() -> str:
    notes = _load_notes()
    if not notes:
        return "(the notebook is empty)"
    return "\n".join(f'#{n["id"]} [{n["tag"]}] {n["text"]}' for n in notes)


@mcp.resource(
    "notes://note/{id}",
    name="one_note",
    description="A single note by id.",
)
def one_note(id: int) -> str:
    notes = _load_notes()
    if id < 1 or id > len(notes):
        raise ResourceError(f"No note has id {id} - the notebook has {len(notes)} note{'s' if len(notes) != 1 else ''}.")
    n = notes[id - 1]
    return f'#{n["id"]} [{n["tag"]}] {n["text"]}'

Probe it over stdio - the listing calls reveal the menu, a read fills the template, and a miss shows you what a resource miss looks like:

uv run --with 'fastmcp==4.0.0' python - <<'EOF'
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
from mcp.shared.exceptions import MCPError

async def main():
    t = StdioTransport(command="uv",
                       args=["run", "--with", "fastmcp==4.0.0", "python", "notes.py"])
    async with Client(t) as c:
        print("resources ->", [r.uri for r in await c.list_resources()])
        print("templates ->", [t.uri_template for t in await c.list_resource_templates()])
        r = await c.read_resource("notes://all")
        print("all       ->", r[0].text)
        r = await c.read_resource("notes://note/1")
        print("note/1    ->", r[0].text)
        try:
            await c.read_resource("notes://note/99")
        except MCPError as e:
            print("note/99   -> expected resource error:", e)

asyncio.run(main())
EOF
resources -> ['notes://all']
templates -> ['notes://note/{id}']
all       -> #1 [shopping] buy oat milk
note/1    -> #1 [shopping] buy oat milk
note/99   -> expected resource error: No note has id 99 - the notebook has 1 note.

The final line is an expected resource error, not a failed server run. FastMCP also writes the corresponding traceback to stderr for the server operator; the client catches the error and continues normally.

The {id} in the URI is not string formatting - it declares a URI template (RFC 6570, also used by templated REST paths). The client learns about it through a separate listing call, and every legal substitution of the template is a readable address. As a rough REST analogy, notes://note/{id} resembles GET /notes/{id}; it is not an HTTP route or a one-to-one mapping.

The probe's last line is an error rather than ordinary resource content. ResourceError tells the protocol that the requested noun could not be read, while preserving a message the client can act on. The SDK boundary turns that server-side exception into MCPError, much as a REST client might turn a 404 response into its own client exception; that is an analogy, not shared wire semantics.

Prompts: versioned wording, server-side

The last primitive needs four lines and no new concepts:

@mcp.prompt(
    name="review_notes",
    description="Ask an LLM to review notes carrying one tag.",
)
def review_notes(tag: str) -> str:
    return (
        f"Read the notes resource notes://all, pick the notes tagged "
        f"'{tag}', and suggest how to organize them into a checklist."
    )

Probe it the same way - list the library, then request one by name, arguments filled in server-side:

uv run --with 'fastmcp==4.0.0' python - <<'EOF'
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StdioTransport

async def main():
    t = StdioTransport(command="uv",
                       args=["run", "--with", "fastmcp==4.0.0", "python", "notes.py"])
    async with Client(t) as c:
        print("prompts ->", [p.name for p in await c.list_prompts()])
        p = await c.get_prompt("review_notes", {"tag": "shopping"})
        print("prompt  ->", p.messages[0].content.text)

asyncio.run(main())
EOF
prompts -> ['review_notes']
prompt  -> Read the notes resource notes://all, pick the notes tagged 'shopping', and suggest how to organize them into a checklist.

The client did not run your function as a tool - it asked for the prompt by name and got back a rendered message. The value is not the formatting

  • it is that the wording lives with the server, versioned and identical for every client. When the review procedure changes, you redeploy the server, not chase seventeen client apps.

One flag to HTTP

Everything so far ran over stdio. Edit the entry point you added in the previous section - same two lines, one flag changes:

if __name__ == "__main__":
    mcp.run(transport="http", host="127.0.0.1", port=8000)
cd ~/mcp-lab
uv run --with 'fastmcp==4.0.0' python notes.py
INFO     Starting MCP server 'notes' with transport 'http' on http://127.0.0.1:8000/mcp
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
Terminal 2

Now curl your own build, exactly like module 1:

SID=$(curl -sD- -o /dev/null -X POST http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
  | grep -i '^mcp-session-id:' | tr -d '\r' | cut -d' ' -f2)
curl -s -o /dev/null -X POST http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H "mcp-session-id: $SID" \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s -X POST http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H "mcp-session-id: $SID" \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":2,"method":"resources/templates/list"}' | grep '^data:' | cut -c7- | jq '.result.resourceTemplates'
[
  {
    "uriTemplate": "notes://note/{id}",
    "name": "one_note",
    "description": "A single note by id."
  }
]

And read through the template - the URI is the address, {id} filled in:

curl -s -X POST http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H "mcp-session-id: $SID" \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"notes://note/1"}}' | grep '^data:' | cut -c7- | jq '.result.contents'
[
  {
    "uri": "notes://note/1",
    "mimeType": "text/plain",
    "text": "#1 [shopping] buy oat milk"
  }
]

Two clients you will actually keep using

Your client.py speaks stdio; point it at HTTP by swapping one line - Client("http://127.0.0.1:8000/mcp") - but there are also two zero-code clients already installed in this playground. The first takes inventory:

cd ~/mcp-lab
~/.local/bin/fastmcp inspect notes.py
Components
  Tools:        3
  Prompts:      1
  Resources:    2
  Templates:    1

Environment
  FastMCP:      4.0.0
  MCP:          2.1.1

(The playground installed this CLI for you; on your own machine it is uv tool install 'fastmcp-slim[server]==4.0.0'.)

The second is the one to internalize. As an operational analogy, fastmcp call is to an MCP server what curl is to a REST one: handshake and all, in one command:

~/.local/bin/fastmcp call http://127.0.0.1:8000/mcp note_stats
{
  "total": 2,
  "per_tag": {
    "shopping": 1,
    "checklist": 1
  }
}

No session juggling, no Accept header acrobics - the CLI client runs the whole lifecycle you did by hand and prints the structured result. That makes it the perfect smoke test for scripts and the perfect debugging reflex: when something misbehaves, try the same call through fastmcp call, then through raw curl, and you have bisected the problem

  • client SDK versus wire versus server - in under a minute.

The contract you are signing

Stop and look at what your server now declares to the world: a name and version (serverInfo), three capabilities (tools, resources, prompts - only the ones you defined), per-tool schemas with parameter-level descriptions, per-resource MIME types, and a prompt library. That surface is your public API, and the same discipline you applied to REST versioning applies here: by analogy, renaming a tool can break clients much as renaming a path does.

One more declaration is worth adopting early - tool annotations, optional metadata that tells hosts what a tool does rather than what it takes:

@mcp.tool(annotations={"readOnlyHint": True})
def note_stats() -> dict:
    ...

readOnlyHint, destructiveHint, idempotentHint - hints, not guarantees, but hosts increasingly use them to decide which calls need a confirmation click. HTTP method semantics are a useful REST analogy; these annotations remain MCP-specific, opt-in metadata.

Before moving on, pick one operation from an API you know. Would you expose it as a tool, resource, or prompt, and what should a missing target look like to the client? If the answer is unclear, its contract probably is too.

Summary

You wrote an MCP server from a bare function to a three-primitive service and ran it in both transports without changing a line of protocol code - because you never wrote any. The protocol is generated from signatures and docstrings; your job was the API design around it: schema-quality type hints, error semantics the caller (a model) can act on, nouns addressed by URI template, and wording that lives server-side.

What is still missing is everything REST taught you to demand from a network endpoint: authentication, and answers to "who called what". Those are the next two modules. Each starts from a canonical fresh-play copy of this server, so you do not need to preserve this play; the ideas, not the filesystem state, are cumulative.

About the Author

Alfonz Bakarbessy

Alfonz Bakarbessy

Find this author online

More tutorials you might like

Harden Access to OpenClaw with Pomerium (cover image)

Harden Access to OpenClaw with Pomerium

Put OpenClaw, a self-hosted AI assistant with shell and file access, behind a web route and an SSH route, both gated by the same identity and Pomerium's context-aware policy. OpenClaw runs in trusted-proxy mode, trusting signed identity headers instead of its own login, while Pomerium's native SSH proxy signs short-lived certificates for shell access.

Using Go for Systems Programming (cover image)

Using Go for Systems Programming

Discover how Go functions under the hood as a modern systems programming language. Learn how Go makes system calls directly, resulting in self-contained binaries that have no libc dependencies.

Learn by doing, not just by reading or watching

Sign up for a free account to start a VM playground right on this page, track your progress, and get notified about new learning materials.

Sign up for free