Skip to main content
MCP (Model Context Protocol) · lesson 2 of 13
MCP (Model Context Protocol) beginner Lesson 2 of 13

Also 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

mcp model-context-protocol python ai-agents

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:

  1. The host’s MCP client calls list_tools() on each connected server, as client.py did.
  2. The host passes those names, descriptions and inputSchemas to the model as function definitions.
  3. When the model asks to call add, the host forwards the call with call_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

  1. Add a multiply(a: int, b: int) -> int tool to server.py. Without changing client.py, what does the tools: line print now?
  2. Give the server a version with MCPServer("calc", version="1.1.0") and print client.server_info.version from the client.
  3. Call add with {"a": 2.5, "b": 1} from inmemory.py. Predict is_error first.
  4. Fix noisy_server.py so the client prints only its structured: 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.

Frequently Asked Questions

What is the Model Context Protocol (MCP)?
MCP is an open protocol that lets an AI application connect to external programs, called servers, that offer tools, data and prompt templates. The application runs an MCP client for each server and talks to it with JSON-RPC messages. One server written once works with any application that speaks MCP, so you don't rewrite the same integration for every app.
Is MCP the same as LLM function calling?
No. Function calling is how a model asks its host application to run a function. MCP is how the host application discovers and calls functions that live in a separate program. A host typically lists a server's MCP tools, passes them to the model as function definitions, and when the model calls one, forwards the call to the MCP server.
Do I need Claude Desktop or an API key to build an MCP server?
No. A server is an ordinary program, and the Python SDK's Client class can launch it and call its tools from a script. That is how this lesson runs every example: pip install mcp, then python client.py, with no model and no account involved.
Why does my stdio MCP server break when I use print()?
Over the stdio transport, the server's stdout carries the protocol itself: one JSON-RPC message per line. A print() call writes a line that isn't JSON into that stream, so the client reports a parse error. Write logs to stderr instead, with print(..., file=sys.stderr) or the logging module.
Which Python SDK version should I use for MCP?
This track uses mcp 2.3.0, which speaks the 2026-07-28 protocol version. Version 2 builds servers with MCPServer and connects with Client. Many older tutorials use FastMCP from version 1, whose import paths and behaviour differ, so check which version a guide was written for.