In Building an AI agent from scratch, our cat tool was hardcoded inside the agent:

# the tool lives inside your program TOOLS = [{"function": {"name": "cat", "...": "..."}}] def run_tool(name, args): ... 

The problem is obvious: switch apps and you rewrite it all. A tool you wrote is only usable by you.

MCP (Model Context Protocol) fixes exactly that — it turns a tool into a standalone process, exposed over one standard protocol that any LLM app can connect to.

In one line: MCP = standardized function calling. It doesn't change the loop from the last post; it just standardizes where tools come from and how they're called.

The three roles

  • Host: the LLM app you use — Claude Desktop, Cursor, or your own agent.
  • Client: a small piece inside the host that talks to the server (usually the host builds it for you).
  • Server: the part you write — a process exposing Tools, Resources and Prompts.

The most common transport is stdio: the client launches the server process and they exchange one JSON per line (JSON-RPC) over stdin/stdout.

Some call it "USB-C for AI" — one port, any device.

A 30-line MCP server

Turn last post's cat into an MCP server (server.py):

import json, subprocess, sys TOOLS = [{ "name": "cat", "description": "Read the full contents of a file", "inputSchema": { "type": "object", "properties": {"path": {"type": "string", "description": "file path"}}, "required": ["path"], }, }] def handle(msg): m = msg["method"] if m == "initialize": return {"protocolVersion": msg["params"]["protocolVersion"], "capabilities": {"tools": {}}, "serverInfo": {"name": "cat-mcp", "version": "0.1"}} if m == "tools/list": return {"tools": TOOLS} if m == "tools/call": path = msg["params"]["arguments"]["path"] out = subprocess.run(["cat", path], capture_output=True, text=True) return {"content": [{"type": "text", "text": out.stdout or out.stderr}]} return {} for line in sys.stdin: req = json.loads(line) if "id" not in req: # a notification — no reply needed  continue res = handle(req) sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": req["id"], "result": res}) + "\n") sys.stdout.flush() 

That's it. An MCP server is just "read a JSON line → dispatch by method → write a JSON line".

A 30-line client

A client does three things: handshake → list tools → call (client.py):

import json, subprocess, sys proc = subprocess.Popen([sys.executable, "server.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) _id = 0 def rpc(method, params=None, notify=False): global _id msg = {"jsonrpc": "2.0", "method": method} if params is not None: msg["params"] = params if not notify: _id += 1 msg["id"] = _id proc.stdin.write(json.dumps(msg) + "\n") proc.stdin.flush() return None if notify else json.loads(proc.stdout.readline()) rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "demo", "version": "0.1"}}) rpc("notifications/initialized", notify=True) # handshake done  print("tools:", rpc("tools/list")) print("call:", rpc("tools/call", {"name": "cat", "arguments": {"path": "/etc/hostname"}})) 

Run it:

python client.py # tools: {'tools': [{'name': 'cat', ...}]} # call: {'content': [{'type': 'text', 'text': 'your-hostname'}]} 

vs. the last post

Last post (hardcoded) This post (MCP)
Tool definition lives in the agent code standalone process, declared via tools/list
Tool invocation a direct function call tools/call JSON-RPC
Who can use it only you any MCP-capable host

Nothing essential changed: still the loop "ask the model → run the tool → feed the result back." MCP just standardizes the tool, so what you write is reusable by any LLM app — swap the model, plug in tools, keep the loop.

📦 Complete runnable code: github.com/zishuowang696/mcp-demo