An MCP client is the part of your app that launches or reaches an MCP server, asks what tools it has, calls them when the model asks, and returns the results. In Python it is about 40 lines. The part that trips people up in late 2026 is that the protocol changed underneath the tutorials: the initialize handshake is gone from the newest revision, and a client has to cope with servers on either side of that line.
This post builds a small client, connects it to a stub server over stdio and HTTP, and bridges the tools to Claude's tool-use format. I ran the client and bridge on 2026-10-30. The model call is a scripted stand-in because I had no API key in this session, and I say so where it matters.
If you need the server side first, see build an MCP server in Python. For what MCP is, read the MCP overview.
What changed in the 2026-07-28 MCP spec for client authors?
Read from the spec's own key-changes page on 2026-10-30, the items that touch a client:
- No handshake.
initializeandnotifications/initializedare removed. Each request carries protocol version and client capabilities in_meta. A version mismatch returnsUnsupportedProtocolVersionError. server/discover. Servers must implement it to advertise versions, capabilities and identity. A client may call it first, and on stdio it is the recommended probe for telling old servers from new ones.- No sessions on HTTP. The
Mcp-Session-Idheader is gone, and list endpoints no longer vary per connection. - Results carry
resultType. Eithercompleteorinput_required. The second replaces server-initiated requests like elicitation with a retry pattern: the server returns what it needs, and the client retries the original call withinputResponses. - Cacheable lists.
tools/listand similar results must includettlMsandcacheScope. - Deprecated, not removed: Roots, Sampling and Logging, plus the old HTTP+SSE transport.
The spec's compatibility page has a matrix. The row that matters for you: a client that supports both eras works with both kinds of server. A modern-only client fails against a legacy server, and a legacy-only client fails against a modern one. Source: the 2026-07-28 key changes and versioning page.
Practical consequence: unless you control every server you connect to, build a dual-era client. The official SDK does this by default.
How do I install the SDK and set up a stub server?
I used the v2 Python SDK (mcp 2.3.0 in a fresh virtualenv, Python 3.14). The SDK docs call v2 the current stable line and list Python 3.11+ as the requirement.
python3 -m venv .venv
.venv/bin/pip install mcp anthropic
A stub server with two tools, so you can test a client without touching anything real:
# stub_server.py
import sys
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("stub-notes")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
@mcp.tool()
def get_note(note_id: str) -> str:
"""Return a note by id. Known ids: n1, n2."""
notes = {"n1": "Buy milk", "n2": "Call the bank"}
if note_id not in notes:
raise ToolError(f"no such note: {note_id}. Known ids: n1, n2")
return notes[note_id]
if __name__ == "__main__":
mcp.run("streamable-http" if sys.argv[1:] == ["http"] else "stdio")
Note ToolError. I'll come back to why.
How do I connect to an MCP server over stdio?
Pass Client a StdioServerParameters. It launches the command as a subprocess and talks over stdin and stdout.
import asyncio, sys
from mcp import Client, StdioServerParameters
from mcp.types import TextContent
async def main():
params = StdioServerParameters(command=sys.executable, args=["stub_server.py"])
async with Client(params) as client:
print("protocol_version:", client.protocol_version)
for t in (await client.list_tools()).tools:
print("tool:", t.name, "|", t.description, "| required:", t.input_schema.get("required"))
res = await client.call_tool("add", {"a": 2, "b": 3})
print([b.text for b in res.content if isinstance(b, TextContent)], res.is_error)
asyncio.run(main())
Real output from my run:
protocol_version: 2026-07-28
tool: add | Add two integers. | required: ['a', 'b']
tool: get_note | Return a note by id. Known ids: n1, n2. | required: ['note_id']
['5'] False
Three details from the SDK client docs and source worth knowing:
Clientis single use. After theasync withblock ends you build a new one.- A failing tool does not raise in your code. It returns a result with
is_error=True. Check it before trustingstructured_content. - Content blocks are a union type. Narrow with
isinstance(block, TextContent)before reading.text.
How do I connect over streamable HTTP?
Pass a URL string instead. Start the stub with python stub_server.py http (it listened on http://127.0.0.1:8000/mcp).
async with Client("http://127.0.0.1:8000/mcp") as client:
r = await client.call_tool("add", {"a": 40, "b": 2})
print(client.protocol_version, r.structured_content, r.is_error)
My output: 2026-07-28 {'result': 42} False.
To see the era difference on the wire, I sent raw requests with curl to the same endpoint. An old-style initialize request got a response with an mcp-session-id header and protocolVersion 2025-11-25, so this SDK's server is dual-era. A modern server/discover request with the version in _meta and no session returned:
{"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"stub-notes","version":""}}}}
Note resultType, ttlMs and cacheScope in the result, straight from the changelog list above. The client's default response cache honors those hints, so repeated list_tools() calls can be served locally when a server sets a nonzero ttlMs. Mine set 0, so nothing was cached.
Will the client still work against older servers?
Yes, and I checked rather than assuming. I ran the same client against a second stub built with the v1.27 SDK (FastMCP, handshake era):
--- legacy server (SDK v1.27)
protocol_version: 2025-11-25
server_info: name='stub-legacy' title=None version='1.27.0' ...
tool: add | Add two integers. | ['a', 'b']
add -> ['5'] is_error: False
It connected and worked, negotiating 2025-11-25. There was a cost: the legacy server's stderr filled with a long pydantic validation warning, because it received a server/discover request it does not know and logged 31 validation errors before the client fell back. That is the probe doing its job, not a bug in your code. If you ship a client that talks to legacy servers, don't surface the child process's stderr to end users, and log it at debug level.
Pin it explicitly if you want a failure instead of a fallback: the Client has a mode argument that accepts "legacy", "auto" (default) or a modern version string.
How do I give MCP tools to Claude?
The tool definitions line up almost one to one. Claude's Messages API wants name, description and input_schema per tool, and MCP tools already have input_schema as JSON Schema (checked against the define tools docs on 2026-10-30). The bridge is a list comprehension, a tool runner that caps output, and a loop.
MAX_TOOL_CHARS = 2000
def to_anthropic_tools(mcp_tools):
return [{"name": t.name, "description": t.description or "",
"input_schema": t.input_schema} for t in mcp_tools]
async def run_tool(client, name, args):
result = await client.call_tool(name, args)
text = "\n".join(b.text for b in result.content if isinstance(b, TextContent))
if len(text) > MAX_TOOL_CHARS:
text = text[:MAX_TOOL_CHARS] + "\n[truncated]"
return {"text": text, "is_error": bool(result.is_error)}
async def agent_loop(client, model, user, max_turns=5):
tools = to_anthropic_tools((await client.list_tools()).tools)
messages = [{"role": "user", "content": user}]
for turn in range(max_turns):
reply = await model(messages, tools) # your Claude call
messages.append({"role": "assistant", "content": reply["content"]})
calls = [b for b in reply["content"] if b["type"] == "tool_use"]
if not calls:
return reply["content"][-1]["text"], turn + 1
results = []
for c in calls:
out = await run_tool(client, c["name"], c["input"])
results.append({"type": "tool_result", "tool_use_id": c["id"],
"content": out["text"], "is_error": out["is_error"]})
messages.append({"role": "user", "content": results})
raise RuntimeError("max_turns reached")
Here is the part I did not run live. The model function that calls Claude would look like this. Unrun in this session (no API key). Check the field names against the current SDK before you rely on it.
import anthropic
claude = anthropic.AsyncAnthropic()
async def model(messages, tools):
r = await claude.messages.create(
model="claude-sonnet-5-5", max_tokens=1024,
tools=tools, messages=messages)
return {"content": [b.model_dump() for b in r.content]}
To exercise everything else, I used a scripted stand-in that asks for get_note with n1, then with the missing n9, then answers. Real output of the loop with that stand-in:
tools offered: ['add', 'get_note']
tool get_note({'note_id': 'n1'}) -> {'text': 'Buy milk', 'is_error': False}
tool get_note({'note_id': 'n9'}) -> {'text': 'Error executing tool get_note: no such note: n9. Known ids: n1, n2', 'is_error': True}
answer: n1 is 'Buy milk'; n9 does not exist. | turns: 3
This proves the plumbing: schema conversion, result handling, is_error propagation, the turn cap. It does not prove Claude picks sensible calls from your tool descriptions. That depends on the descriptions; the tool description checklist covers them.
Why did my tool error say only "Error executing tool"?
My first run used a plain ValueError in the stub's get_note. The client saw is_error: True and the text Error executing tool get_note, with the cause only in the server's log. The SDK's ToolError docstring explains it: ToolError is for failures you anticipated, and the message reaches the model. Any other exception is treated as a crash, and the model sees only the generic line.
Why it matters to a client author: the model reads that error text and decides what to do next. A generic message gives it nothing to correct, so it either retries the same call or gives up. A message like no such note: n9. Known ids: n1, n2 lets it fix its own input, as the output above shows. If the server is not yours, treat the bare generic message as a crash signal. Count it, don't loop on it.
What should a production MCP client add?
None of this was tested here beyond the pieces above; it is a checklist, not a result.
| Concern | What to do |
|---|---|
| Which tools reach the model | Allowlist reviewed tool names. Namespace by server when you connect several (notes_get_note). |
| Tool output size | Cap and truncate before returning to the model, as in run_tool. |
| Approval for risky tools | Gate writes and deletes behind a human check; see human-in-the-loop approval gates. |
| Untrusted output | Tool results are text from someone else; see prompt injection for tool-using agents and the MCP security checklist. |
| Loops | Keep max_turns and a token budget; see token budgeting for agents. |
| Remote servers | Remote HTTP servers add OAuth; see MCP authorization explained. |
| Child process noise | Send stdio server stderr to a log file, not the terminal. |
What I could not verify
- Live Claude behavior with these tools (unrun, no key).
- OAuth flows and remote servers behind auth. I only tested localhost.
- The tasks extension and the
input_requiredretry pattern. The SDK client drives that loop (input_required_max_rounds), but my stub never returned one. - Behavior on Windows. Everything ran on macOS.



