← All posts

Write a standard MCP client by hand (no server needed)

The previous post covered the agent loop; now we enter MCP.

Most people get stuck on the same question: what exactly is the MCP standard? — because the spec directly decides how the client is written. So this post writes no server. It does one thing: explain the MCP standard, then implement a standard MCP client in stdlib code that can connect to any MCP server.

What the MCP standard actually is

In one line:

MCP = a JSON-RPC 2.0 protocol: it defines which methods exist, what messages look like, and how the handshake and capability negotiation work. stdio / HTTP are just the transport beneath it.

1) Message shapes (JSON-RPC 2.0)

  • Request: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{...}}
  • Response: {"jsonrpc":"2.0","id":1,"result":{...}} (or error)
  • Notification: {"jsonrpc":"2.0","method":"notifications/initialized"} (no id, no reply expected)

2) Lifecycle: one handshake

  1. client → initialize (with protocolVersion, capabilities, clientInfo);
  1. server → returns its capabilities and serverInfo (version mismatch is negotiated or errors);
  1. client → notifications/initialized (handshake done).

Only then do real calls begin.

3) Capability negotiation — the key

During the handshake both sides declare what they support, which decides which methods you may call:

Provided byCapabilityMethods
server → clienttoolstools/list, tools/call
resourcesresources/list, resources/read
promptsprompts/list, prompts/get
client → serversamplingsampling/createMessage
rootsroots/list

How to write a client = handshake → read the server's capabilities → call only the methods it declared.

Implementation: a standard MCP client

Transport is stdio (launch the server as a child, read/write its stdin/stdout). The core is one MCPClient class:

import json, subprocess

class MCPClient:
    def __init__(self, cmd):
        self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
        self._id = 0
        self.capabilities = {}

    def _send(self, obj):
        self.proc.stdin.write(json.dumps(obj) + "\n"); self.proc.stdin.flush()

    def _request(self, method, params=None):
        self._id += 1
        msg = {"jsonrpc": "2.0", "id": self._id, "method": method}
        if params is not None: msg["params"] = params
        self._send(msg)
        while True:
            resp = json.loads(self.proc.stdout.readline())
            if resp.get("id") == self._id and ("result" in resp or "error" in resp):
                if "error" in resp: raise RuntimeError(resp["error"])
                return resp["result"]

    def _notify(self, method, params=None):
        msg = {"jsonrpc": "2.0", "method": method}       # notification: no id
        if params is not None: msg["params"] = params
        self._send(msg)

    def initialize(self, protocol_version="2024-11-05"):
        result = self._request("initialize", {
            "protocolVersion": protocol_version,
            "capabilities": {},
            "clientInfo": {"name": "mini-mcp-client", "version": "0.1"},
        })
        self.capabilities = result.get("capabilities", {})
        self._notify("notifications/initialized")
        return result

    def list_tools(self):
        return self._request("tools/list").get("tools", [])

    def call_tool(self, name, arguments):
        return self._request("tools/call", {"name": name, "arguments": arguments})

What it does (mapped to the standard):

  1. Transport: spawn a child + pipe I/O;
  1. Handshake: initialize → get capabilities → notifications/initialized;
  1. Calls: tools/list to discover tools → tools/call to run one.

Run it (against any server)

python client.py python3 ../mcp-demo/server.py
handshake ok, serverInfo = {'name': 'cat-mcp', 'version': '0.1'}
server capabilities = ['tools']
tools = ['cat']
call cat -> {'content': [{'type': 'text', 'text': 'your-hostname'}]}

Note: there is no "our server" here — the client only expects "a server that satisfies the MCP standard". Point it at any stdio MCP server and the same client works.

Common pitfalls

  1. Notifications have no id: don't wait for a reply to notifications/initialized.
  1. Match responses by id: correlate by id (this example is serial for simplicity).
  1. Call only declared methods: if the server declares no resources, don't call resources/list.
  1. Negotiate protocolVersion: a mismatch errors; don't hardcode blindly.
  1. Servers send messages proactively (progress, logs, sampling requests): a real client dispatches them; this example just skips them.

In one line

MCP = JSON-RPC 2.0 + a set of defined methods + handshake/capability negotiation. Understand that, and a client is three steps: handshake → read capabilities → call the right methods — independent of stdio vs HTTP.

The next post switches to the server side: also 30 lines, exposing a tool over MCP.

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

💬 Questions or feedback? Leave a comment below, or open an Issue on GitHub.

Comments 0

No comments yet — be the first!

Log in Log in to comment