Module 4 · Model Context Protocol (MCP)

Connecting MCP Servers to Clients

Lesson 15 of 25 11 min read Updated

An MCP server is only useful when a client connects to it. The client is the part of your application (or of a desktop AI app) that starts or reaches the server, agrees on a protocol version, asks what tools exist, and forwards tool calls. In this lesson you will learn how that connection works, how to configure it, and how to write your own client with the official Python SDK.

Overview: how a connection works

MCP has three roles. The host is the application the user sees, for example a chat app or your own agent program. Inside the host lives one client object per server. Each client keeps a one-to-one session with one server. If your agent uses three servers, your host holds three client sessions.

Messages are JSON-RPC 2.0. A request has an id and expects a response. A notification has no id and expects nothing back. The messages travel over a transport. Two transports matter in practice:

Transport How it works Typical use
stdio The client launches the server as a child process and talks over its standard input and output. Local tools on the user’s own machine (files, git, local databases).
Streamable HTTP The server runs independently and the client sends HTTP requests to one endpoint URL. Remote or shared servers, servers that need login or many users.

Older material mentions an HTTP+SSE transport. It has been superseded by streamable HTTP, so prefer streamable HTTP for new work.

The lifecycle

  1. Connect: the transport is opened (process started or HTTP endpoint reached).
  2. Initialize: the client sends an initialize request with its protocol version and capabilities. The server answers with its own version, capabilities and name. The client then sends an initialized notification. Nothing else should be sent before this handshake.
  3. Discover: the client calls tools/list (and, if needed, resources and prompts lists).
  4. Operate: the client calls tools, reads resources, and so on. Your agent loop decides when.
  5. Shutdown: the transport is closed. For stdio, the child process is ended.

Capability negotiation is why a client can safely talk to servers written by other people: each side declares what it supports, and only those features are used.

Key concepts and the Python SDK

Install the official SDK with the CLI extras, which also give you a debugging inspector:

pip install "mcp[cli]"
mcp dev server.py

The shape of every Python client is the same:

transport context manager  ->  (read_stream, write_stream)
ClientSession(read, write) ->  session
await session.initialize()
await session.list_tools()
await session.call_tool(name, arguments)
  • StdioServerParameters describes how to launch a local server: command, args and optionally env.
  • stdio_client(params) starts the process and gives you the read and write streams.
  • streamablehttp_client(url) does the same for an HTTP server (it also yields a third value, a session-id getter, that you can ignore).
  • ClientSession is the protocol layer. It is independent of the transport, so your agent code does not change when you move a server from local to remote.
  • call_tool returns a result with a content list (text, image or other items) and an isError flag.

Examples

Example 1: a small server

First, a server to connect to. It uses FastMCP from the official SDK and exposes two harmless tools.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("demo-tools")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b


@mcp.tool()
def word_count(text: str) -> int:
    """Count words in text."""
    return len(text.split())


if __name__ == "__main__":
    mcp.run(transport="stdio")

Save this as server.py. FastMCP builds each tool’s name, description and JSON input schema from the function name, docstring and type hints. Running the file directly starts a stdio server that waits for a client; on its own it prints nothing.

Example 2: a Python client over stdio

import asyncio
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def main() -> None:
    params = StdioServerParameters(command=sys.executable, args=["server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            tools = await session.list_tools()
            for tool in tools.tools:
                print(tool.name, "-", tool.description)

            result = await session.call_tool("add", {"a": 2, "b": 3})
            print(result.content[0].text)
            print(result.isError)


asyncio.run(main())

Output:

add - Add two integers.
word_count - Count words in text.
5
False

Run it from the folder that contains server.py. We use sys.executable so the child process uses the same Python (and the same installed mcp package) as the client. The integer result arrives as text content "5", because tool results are content items, not Python objects.

Example 3: connecting over streamable HTTP

To run the same server as a network service, change only the last line of the server:

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

With the SDK defaults the server listens on local port 8000 and serves MCP at the path /mcp. Check the SDK documentation for your installed version if you need to change host, port or path. The client then swaps one line:

import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def main() -> None:
    url = "http://127.0.0.1:8000/mcp"
    async with streamablehttp_client(url) as (read, write, _get_session_id):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool("word_count", {"text": "connect MCP servers"})
            print(result.content[0].text)


asyncio.run(main())

Output (with the server already running):

3

Everything after the transport line is identical to Example 2. That is the benefit of the layered design.

Example 4: configuring an existing host app

Desktop AI apps and coding assistants let you register servers in a JSON configuration file instead of writing a client. Many hosts, including Claude Desktop, use an mcpServers object:

{
  "mcpServers": {
    "demo-tools": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"],
      "env": {
        "DEMO_API_KEY": "your-key-here"
      }
    }
  }
}

Each key under mcpServers is a name you choose. command and args are exactly what the host will run as a child process. env passes environment variables, which is the right place for secrets (never hard-code them in server source). The config file’s location and exact fields differ between hosts and versions, so always check your host’s current documentation. Restart the host after editing.

Some command-line hosts can register servers with a command. For example, Claude Code offers an mcp add subcommand:

claude mcp add demo-tools -- python /absolute/path/to/server.py

Run claude mcp --help to see the options in your version, including the one for HTTP servers.

Under the hood: what stdio really does

When you call stdio_client, the SDK starts your server with a pipe attached to its standard input and standard output. Each JSON-RPC message is written as a single line of JSON. The client writes requests to the server’s stdin and reads responses from its stdout. The server’s stderr is free for logs.

This explains the most common beginner bug: if your server prints anything else to stdout, the client tries to parse it as a protocol message and the session breaks. With streamable HTTP this problem does not exist, because messages travel in HTTP bodies, so printing is harmless there.

With HTTP, the first request is the initialize call. The server may return a session id in a header and the client sends it on later requests. The server can answer a request with plain JSON or with an event stream, which lets it send progress and notifications while a long tool runs. The SDK hides these details.

Putting an approval gate in the host

Connecting a server gives the model real power. A tool named like delete_file can destroy data, and a tool that reads web pages or emails can return text that tries to instruct your agent (prompt injection). The host, not the server, is where you enforce policy. This runnable example uses a fake session so it works offline; in a real agent you pass the real ClientSession and the model’s chosen tool call (from your call_llm() function).

READ_ONLY_TOOLS = {"add", "word_count"}


class FakeSession:
    """Stands in for ClientSession so the example runs without a server."""

    async def call_tool(self, name, arguments):
        return f"ran {name} with {arguments}"


async def guarded_call(session, name, args, approve):
    if name not in READ_ONLY_TOOLS and not approve(name, args):
        return "DENIED by user"
    return await session.call_tool(name, args)


async def main():
    session = FakeSession()
    deny = lambda name, args: False
    allow = lambda name, args: True
    print(await guarded_call(session, "add", {"a": 1, "b": 2}, deny))
    print(await guarded_call(session, "delete_file", {"path": "x"}, deny))
    print(await guarded_call(session, "delete_file", {"path": "x"}, allow))


if __name__ == "__main__":
    import asyncio

    asyncio.run(main())

Output:

ran add with {'a': 1, 'b': 2}
DENIED by user
ran delete_file with {'path': 'x'}

Known read-only tools run directly. Anything else needs approval. In a real app, approve would show the user the tool name and arguments and wait for a click. To swap in a real model, replace your stub call_llm() with an SDK call, convert the tools from list_tools() into that provider’s tool format, and route each tool call the model returns through guarded_call.

Common Mistakes

Mistake 1: printing to stdout in a stdio server

@mcp.tool()
def add(a: int, b: int) -> int:
    print("adding", a, b)   # WRONG for stdio servers
    return a + b

Why it is wrong: stdout is the protocol channel, so the stray line corrupts the JSON-RPC stream and the client may fail or hang. Fix it by logging to stderr:

import sys


@mcp.tool()
def add(a: int, b: int) -> int:
    print("adding", a, b, file=sys.stderr)
    return a + b

Mistake 2: skipping initialize()

async with ClientSession(read, write) as session:
    tools = await session.list_tools()   # WRONG: no handshake yet

The handshake negotiates versions and capabilities. Always call await session.initialize() first.

Mistake 3: relative paths and missing environments in host configs

A host launches your server from its own working directory, not yours. "args": ["server.py"] often fails with file-not-found. Use absolute paths, and point command at the Python that has mcp installed (for example a virtual environment’s interpreter).

Best Practices

  • Use stdio for local, single-user tools and streamable HTTP for shared or remote servers.
  • Always use absolute paths in host configuration, and test the exact command in a terminal first.
  • Debug a new server with mcp dev server.py (the inspector) before attaching it to an agent.
  • Keep secrets in env or a secret manager, never in source code or prompts.
  • Give each server only the permissions it needs: a read-only folder, a read-only database user, narrow API scopes.
  • Treat tool results as untrusted data. Never let text returned by a tool act as instructions to the agent.
  • Require human approval for tools that write, delete, send or spend, and show the full arguments.
  • Only install servers from sources you trust; a local server runs with your user’s rights.
  • Use timeouts and handle isError results so one failing server does not freeze the agent.
  • Use async with so processes and connections are always cleaned up.

Practice Exercises

  1. Extend server.py with a reverse_text tool. Write a client that lists all three tools and calls the new one. Hint: text[::-1] reverses a string.
  2. Write a config JSON entry for server.py using an absolute path and one environment variable. Explain why each field is needed.
  3. Change guarded_call so that approval is also required when a read-only tool is called with a string argument longer than 1000 characters. Explain what risk this reduces.

Summary

  • A host holds one client session per server; messages are JSON-RPC over a transport.
  • stdio launches a local child process; streamable HTTP reaches a running server by URL.
  • Every session starts with initialize(), then discovery, then tool calls.
  • The Python SDK uses stdio_client or streamablehttp_client with ClientSession, so agent code stays the same across transports.
  • Host configs use command, args and env; use absolute paths and check your host’s docs.
  • Never print to stdout in a stdio server; log to stderr.
  • The host must enforce least privilege, human approval for risky tools, and distrust of tool output.

Progress is saved in this browser.