MCP (Model Context Protocol) · lesson 2 of 13
Beginner
4Intermediate
4Advanced
5Also part of MCP (Model Context Protocol)
5Also part of: LLM Engineering AI Agents
MCP: Your First Server and Client
Build a Model Context Protocol server with one tool in Python, call it from a scripted client over stdio and in memory, and see why stdout is off limits.
By SkillByExample · Published
Why a Protocol
An AI application wants to read your issue tracker, query a database and search your docs. Without a shared protocol, every application writes its own integration for every system. The Model Context Protocol (MCP) splits the work in two. A server wraps one system and offers it as tools, resources and prompts. A host application (an IDE, a chat app, an agent) runs one MCP client per server and talks to it in JSON-RPC.
This track builds servers and drives them from scripted clients, so you can see every request and response. Nothing here calls a model or needs an account. Install the SDK once:
$ pip install mcp==2.3.0
The examples use version 2 of the Python SDK (MCPServer, Client) and protocol version 2026-07-28. Tutorials written for version 1 import FastMCP instead, and their code won’t run unchanged.
A Server With One Tool
A server is a normal Python program. Decorate a function with @mcp.tool() and the SDK turns it into a tool, using its type hints for the argument schema and its docstring for the description.
# server.py - a minimal MCP server with one tool
from mcp.server import MCPServer
mcp = MCPServer("calc")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
if __name__ == "__main__":
mcp.run() # stdio is the default transport
Run on its own, this server waits silently for JSON on stdin. The client starts it.
# client.py - launch server.py and call its tool; no model involved
import asyncio
from mcp import Client, StdioServerParameters
server = StdioServerParameters(command="python", args=["server.py"])
async def main():
async with Client(server) as client:
print("protocol:", client.protocol_version)
print("server:", client.server_info.name)
tools = await client.list_tools()
print("tools:", [t.name for t in tools.tools])
result = await client.call_tool("add", {"a": 2, "b": 3})
print("content:", result.content)
print("structured:", result.structured_content)
asyncio.run(main())
$ python client.py
protocol: 2026-07-28
server: calc
tools: ['add']
content: [TextContent(type='text', text='5', annotations=None, meta=None)]
structured: {'result': 5}
Client(server) launched python server.py as a subprocess and connected to its stdin and stdout. That’s the stdio transport. Entering the async with block agreed a protocol version with the server (2026-07-28) and learned the server’s name. The client then asked what tools exist and called one.
The result comes back twice. content is a list of content blocks: text, images or resources that a host can show a model. structured_content is the same answer as JSON, wrapped as {"result": 5} because the function returns a bare int. The SDK fills in both from one return a + b.
Connecting Without a Subprocess
Client also accepts the server object itself. It then runs the server in the same process, which is how you test a server.
# inmemory.py - connect to the server object directly, no subprocess
import asyncio
from mcp import Client
from server import mcp
async def main():
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": 40, "b": 2})
print("structured:", result.structured_content)
print("is_error:", result.is_error)
asyncio.run(main())
$ python inmemory.py
structured: {'result': 42}
is_error: False
Importing server didn’t start a stdio loop, because mcp.run() sits behind if __name__ == "__main__". The tool code is the same; only the transport changed. The HTTP and testing lesson uses this to test servers with pytest.
is_error is how a tool reports failure. False means the tool ran and returned normally. The tools lesson shows what a failing call returns.
Stdout Belongs to the Protocol
Over stdio, every line the server writes to stdout must be a JSON-RPC message. Here’s a server that prints debug text both ways.
# noisy_server.py - a stdio server that prints debug text to stdout
import sys
from mcp.server import MCPServer
mcp = MCPServer("noisy")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
print(f"adding {a} and {b}") # stdout: the protocol channel
print(f"adding {a} and {b}", file=sys.stderr) # stderr: safe for logs
return a + b
if __name__ == "__main__":
mcp.run()
noisy_client.py is client.py with args=["noisy_server.py"], and it prints only the structured: line. The grep drops the indented lines of a traceback.
# noisy_client.py - call the noisy server's tool
import asyncio
from mcp import Client, StdioServerParameters
server = StdioServerParameters(command="python", args=["noisy_server.py"])
async def main():
async with Client(server) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
print("structured:", result.structured_content)
asyncio.run(main())
$ python noisy_client.py 2>&1 | grep -v '^ '
adding 2 and 3
Failed to parse JSONRPC message from server
Traceback (most recent call last):
pydantic_core._pydantic_core.ValidationError: 1 validation error for union[JSONRPCRequest,JSONRPCNotification,JSONRPCResponse,JSONRPCError]
structured: {'result': 5}
The first adding 2 and 3 is the stderr copy. The client passes the server’s stderr straight through to your terminal, which is where logs belong. The stdout copy never appears as text. The client read it as a protocol message, failed to parse it as JSON, and logged the error. This client then skipped the bad line, so the call still returned 5, but don’t build on that. Stdout is the server’s half of the conversation, and anything else written there is noise the client has to survive. In a stdio server, log to stderr or use the logging module, whose default handler writes to stderr.
MCP and Function Calling
MCP doesn’t replace LLM tool use. It sits one layer below it. In a host application the flow is:
- The host’s MCP client calls
list_tools()on each connected server, asclient.pydid. - The host passes those names, descriptions and
inputSchemas to the model as function definitions. - When the model asks to call
add, the host forwards the call withcall_tool()and hands the result back to the model.
The model never talks to the server. The scripted client here plays the host’s part without a model, so every call you see is one a real host would make.
Practice
- Add a
multiply(a: int, b: int) -> inttool toserver.py. Without changingclient.py, what does thetools:line print now? - Give the server a version with
MCPServer("calc", version="1.1.0")and printclient.server_info.versionfrom the client. - Call
addwith{"a": 2.5, "b": 1}frominmemory.py. Predictis_errorfirst. - Fix
noisy_server.pyso the client prints only itsstructured:line and the stderr log.
Solution 1
@mcp.tool()
def multiply(a: int, b: int) -> int:
"""Multiply two integers."""
return a * b
The client lists whatever the server registers, so the line becomes tools: ['add', 'multiply'] with no client change. That’s the point of discovery: hosts don’t hard-code a server’s tools.
Solution 2
mcp = MCPServer("calc", version="1.1.0")
print("version:", client.server_info.version)
It prints version: 1.1.0. The server sends its name and version in the _meta of every response, which the wire protocol lesson prints raw.
Solution 3
is_error is True. The schema says a is an integer, and the SDK validates arguments before running the function, so 2.5 is rejected with “Input should be a valid integer, got a number with a fractional part” in content. 2.0 would pass, because it has no fractional part. The tools lesson prints that message.
Solution 4
Delete the stdout print, or send it to stderr:
print(f"adding {a} and {b}", file=sys.stderr)
The parse error disappears, and adding 2 and 3 still reaches your terminal through stderr.