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.
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.
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.
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
@(
'{"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
{"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.
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/.
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:
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.
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"}}}'
$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
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.
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.
ngrok config add-authtoken YOUR_TOKEN_HERE ngrok http 8000
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:
{
"mcpServers": {
"toolsmith": {
"url": "https://a1b2-203-0-113-7.ngrok-free.app/mcp/"
}
}
}
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.
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.
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.
Point your agent at a repo you already have and let it do the wrapping. Paste this verbatim:
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.