labs | 02 | mcp server
lab 02 | ~10 min | masterclass

One decorator, three tools, one Resource. Then flip one argument and your laptop is on the public internet.

MCP (Model Context Protocol, the standard way an agent calls your code) is how you hand an agent your functions. You do not need a heavy software development kit or a cloud account. You need FastMCP (a small Python library that turns plain functions into MCP tools), a decorator, and one setting changed. We start with a server that runs over a local pipe, then change a single argument so the exact same tools answer over the network, then put that on the open internet. By the end an agent can call your repo over the web.

step 1

The minimal server, then boot it and ping it.

Install once: pip install fastmcp (v3.x). Then save this as server.py. The decorator reads your type hints and docstring and writes the schema for you (the schema is the machine-readable description of a tool's inputs that the agent reads). You never hand-write one.

server.py
from fastmcp import FastMCP

mcp = FastMCP("toolsmith")

@mcp.tool
def add(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b

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

Boot it with python server.py. It sits silent, waiting for input. It speaks JSON-RPC (a simple request-and-response format sent as plain text) over stdio (standard input/output, just piping text into a program and reading text back). Silence is correct here: a stdio server is a pipe, not a web server, so it prints no banner. Now talk to it. In a fresh shell, pipe three messages in. This is exactly the sequence an agent sends: initialize (the opening handshake), then notifications/initialized, then a tools/call for add.

bash | zsh | ping over stdio
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}' \
  | python server.py
powershell | ping over stdio
@(
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
  '{"jsonrpc":"2.0","method":"notifications/initialized"}'
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}'
) | python server.py
what you'll see (the tools/call reply, id 2)
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"5"}],
 "structuredContent":{"result":5},"isError":false}}

The "text":"5" is your function's return value, sent back across the connection. The server ran your Python. That is a real MCP call, and you made it with nothing but a pipe.

step 2

Flip to Streamable HTTP.

stdio is a local pipe: one client, one machine, gone when you close the terminal. Real deployments use Streamable HTTP instead (the way MCP runs over the web, so any client on any machine can reach it). The server code does not change. You edit the one run() line, and the same add tool now answers over the web at /mcp/.

server.py (the diff)
    mcp.run()
    mcp.run(transport="http", host="127.0.0.1", port=8000)

Here transport means how the server talks to clients, and transport="http" selects the web path. The string "streamable-http" means the same thing, so you will see both names used. Boot it again with python server.py. This time it claims a network port and logs that it is listening:

what you'll see
Starting MCP server 'toolsmith' with transport 'http'
Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
  -> MCP endpoint: http://127.0.0.1:8000/mcp/

Now send an initialize request to it. Streamable HTTP needs the client to accept two reply formats, plain JSON and an SSE stream (server-sent events, a long-lived connection the server can keep pushing messages down), so the Accept header lists both. Pass -L too. FastMCP serves at /mcp/ with a trailing slash and redirects the no-slash form, and curl will not follow that redirect without -L. Skip it and you get a silent empty reply.

terminal | initialize over http (-i prints headers)
curl -s -i -L -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":"0"}}}'
powershell | initialize over http
$body = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
curl.exe -s -i -L -X POST http://127.0.0.1:8000/mcp `
  -H "Content-Type: application/json" `
  -H "Accept: application/json, text/event-stream" `
  -d $body
what you'll see (HTTP 200, an mcp-session-id header, then the SSE frame)
HTTP/1.1 200 OK
mcp-session-id: 7f3a1c8e9b2d4f60a1c2e3d4f5a6b7c8
content-type: text/event-stream

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18",
 "capabilities":{"tools":{"listChanged":true}},
 "serverInfo":{"name":"toolsmith","version":"3.3.1"}}}

Here is the one catch that stdio let you ignore. Over the web the server hands you an mcp-session-id header on initialize, and the client must send it back on every later call (add -H "mcp-session-id: <value>"). With stdio there was one connection, the pipe itself, so no session id was needed. Over the web there are many clients at once, so each session has to be named.

step 3

Expose it to the public internet.

Your server listens on localhost (your own machine, reachable only from this computer). ngrok is a tool that opens a secure tunnel: it gives you a public web address that forwards traffic to your local port 8000, so the outside world can reach it. One-time setup: sign up free, grab your authtoken (the key that links the tool to your account, which takes a minute to create on a new one), and register it. Then point ngrok at the port.

terminal | one-time setup, then leave running
ngrok config add-authtoken YOUR_TOKEN_HERE
ngrok http 8000
what you'll see
Forwarding   https://a1b2-203-0-113-7.ngrok-free.app -> http://localhost:8000

Your public MCP address is that URL with /mcp/ on the end. Now register it with a client so an agent can use it. A client is whatever runs the agent (here, your coding agent). Drop this into the client's .mcp.json config file, or run the claude mcp add line, swapping in your own ngrok id:

.mcp.json (register the remote server)
{
  "mcpServers": {
    "toolsmith": {
      "url": "https://a1b2-203-0-113-7.ngrok-free.app/mcp/"
    }
  }
}
terminal | or register it from the CLI
claude mcp add toolsmith --transport http https://a1b2-203-0-113-7.ngrok-free.app/mcp/

An agent on a machine you have never seen can now connect to toolsmith and call add. The tool did not change between step 1 and now. Only its reach did.

one argument is the whole difference between a toy and a servicetransport pivot

Here is the lesson the rest of the room will miss. With mcp.run() you have stdio: one client, local only, dead the moment you close the terminal. Most people demo there, screenshot the green reply, and then wonder why nothing out in the world can reach their server.

Change that one line to transport="http" and the exact same tools are served to any agent over the network. The tools did not change. The decorator did not change. Your functions did not change. What changed is who can reach them. That single argument is what turns "I built an MCP server" into "an agent I have never met can call my MCP server."

That is also why leaving stdio on in production is the number-one mistake people make with MCP. It is not wrong, it is just local. The change is one argument, and you have already made it.

remote agent, after you registered the ngrok URL I discovered an MCP server named "toolsmith" with a tool add(a, b). I need 2 + 3. Calling add(a=2, b=3) over HTTPS. [result: 5]

checkpoint

You have a running MCP server, reachable over the web, that any agent can call. You booted it over stdio, proved it with a raw JSON-RPC ping, changed one argument to Streamable HTTP, handled the mcp-session-id the way a real client does, and put it on the public internet behind a registered client. The next lab makes sure a small model can actually figure out when to call your tools, because a tool nobody can find the right moment for is no better than no tool at all.

hand this to your coding agent

Point your agent at a repo you already have and let it do the wrapping. Paste this verbatim:

prompt
Take the three most useful functions in <repo> and wrap each as an
@mcp.tool in a FastMCP server. Serve them over Streamable HTTP on port
8000. Then give me the .mcp.json entry and the `claude mcp add` command
to register this server with my coding agent, and a one-line curl to
confirm tools/list returns all three.