Module 6: MCP & Tool Ecosystems · Lesson 3 of 5 · 35 min

Clients & Transports: stdio and Streamable HTTP

A server nobody can talk to is a file of decorators. Write a minimal client so the protocol stops being abstract, then choose transports deliberately: stdio for local single-user tools, streamable HTTP for anything remote or shared.

Writing a client teaches you what hosts like Claude Desktop actually do on your behalf, and Lab 06's integration tests need one. The Python SDK gives you the pieces: a transport (spawn a subprocess for stdio, or open an HTTP connection) and a ClientSession that performs the handshake and speaks the protocol. Fifteen lines of async code and you can list and call tools programmatically — which is exactly what an integration test is.

a minimal stdio client
# client.py — connects to server.py over stdio.
# This is a TWO-FILE program: save Lesson 2's server as server.py next to
# this file, then run `python client.py` from a terminal (pip install mcp
# first). It spawns the server as a subprocess, so it isn't a single Colab
# cell — the client owns the server's whole lifetime.
import asyncio

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


async def main() -> None:
    params = StdioServerParameters(
        command="python",
        args=["server.py"],
        env={"ORDERS_API_KEY": "test-key-for-dev"},
    )
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()          # the handshake from Lesson 1

            tools = await session.list_tools()
            for t in tools.tools:
                print(t.name, "-", t.description.splitlines()[0])

            result = await session.call_tool(
                "search_orders",
                arguments={"query": "late delivery", "status": "open"},
            )
            print(result.content[0].text)


asyncio.run(main())
Read it against Lesson 1's JSON: initialize() is the handshake, list_tools() is tools/list, call_tool() is tools/call — the SDK is a thin, typed veneer over those messages. The client spawns the server as a subprocess and owns its lifetime; env is how tests inject fake credentials. This exact pattern, wrapped in pytest with assertions on result.content, is your Lab 06 integration test.

Choosing a transport

stdioStreamable HTTP
TopologyClient spawns server as a local subprocess; pipes are the wireServer is an independent HTTP service; clients connect over the network
UsersOne client, one server instance, one machineMany concurrent clients, sessions multiplexed
AuthInherits local trust — whoever can run the processRequired: token/OAuth-based auth at the HTTP layer
OpsNothing to deploy; dies with the clientDeploy, monitor, scale like any web service; supports streaming responses and resumable sessions
FitsPersonal dev tools, Claude Desktop/Code local servers, Lab 06's defaultTeam-shared servers, SaaS integrations, anything centrally updated

The decision rule is boring and correct: local and single-user → stdio; remote or multi-user → streamable HTTP. Streamable HTTP is the modern remote transport in the spec — a single endpoint handling POSTed messages with optional streamed (SSE-style) responses and session resumability; it replaced the older separate HTTP+SSE arrangement. Don't reach for HTTP because it feels more "production": a personal GitHub helper on your laptop gains nothing from being a web service, and stdio's process-per-client model gives you isolation for free.

same server, remote transport
# FastMCP servers switch transports at run() time -- the tools,
# resources, and prompts don't change at all.

if __name__ == "__main__":
    import sys

    if "--http" in sys.argv:
        # serves the MCP endpoint over streamable HTTP on a port
        mcp.run(transport="streamable-http")
    else:
        mcp.run()    # stdio default: spawned by the client
Transport is deliberately orthogonal to capability — that's the protocol working as designed. What does change with HTTP is everything around the process: you now need authentication (the spec defines an OAuth-based flow for remote servers; at minimum, require a bearer token), TLS, and to treat every incoming session as untrusted. Exact serving details (port flags, mounting in an ASGI app) vary by SDK version — check its docs rather than memorizing.
Remote servers are attack surface
The moment your server has a URL, it's a web service holding credentials for a real API. Minimum bar: authenticate every client, run TLS, scope the server's own API tokens to the narrowest permissions that work, and log every tool call with caller identity. A stdio server inherits your laptop's trust boundary; an HTTP server has to build its own.
MCP clienthost app / agentMCP serveryour toolsinitialize (protocol version, capabilities)capabilities: tools, resources, promptstools/list[{name: "query_db", inputSchema: {…}}, …]tools/call → query_db({sql: "SELECT …"})content: [{type: "text", text: "42 rows…"}]
Same handshake, either wire: stdio pipes to a subprocess, or streamable HTTP to a remote service — capabilities and messages are identical.
1/6

OAuth for remote servers, concretely

The spec's answer to 'how does a remote MCP server authenticate clients' is an OAuth 2.1-based flow modeled on standard delegated authorization: the client discovers (or is configured with) the server's authorization endpoints, redirects the user through a login/consent screen, and receives a token it attaches to subsequent HTTP requests as a bearer credential — the server never sees the user's actual account password, and the token can be scoped and revoked independently of it. Two consequences worth internalizing for a design or debugging conversation: first, the token is a bearer credential — anyone holding it can act as that client, so streamable HTTP transport isn't 'secure' merely because it does OAuth; the token still has to be stored safely and transmitted only over TLS. Second, OAuth authenticates the client, not each individual tool call — a compromised client with a valid, unexpired token can call every tool the token's scope allows, which is exactly why least-privilege scoping (Lesson 5) matters as much on the token side as on the server's own downstream API credentials. A stdio server skips all of this by inheriting the OS-level trust of whoever can run the process — a legitimate simplification for a single local user, not a workaround to avoid implementing auth 'for now' on something that's actually going to be shared.

Predict the output
Predict: A team ships their internal MCP server over streamable HTTP with a hardcoded, never-rotated bearer token shared by every internal client — 'it's just for the office, and everyone's already on the VPN.' Six months later, what's the most likely way this goes wrong?

Skipping the client entirely: provider-side MCP connectors

Once your server speaks streamable HTTP, you don't even have to run the MCP client yourself: both major model providers can attach a remote MCP server server-side, on their end of the API call. You pass the server's URL in the request; the provider's infrastructure performs the handshake, injects the discovered tool schemas, executes tools/call round trips, and returns the finished conversation — your code makes one chat-API call and never touches JSON-RPC. The MCP server itself is identical in both cases, which is the whole point of the protocol: one server, every host, including hosts that are someone else's API.

attaching a remote MCP server to a model call, server-side
# Colab cell — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted. Note: this needs a
# REAL, reachable MCP server at the URL below — swap in one you've deployed.
!pip install -q anthropic

import os
try:
    from google.colab import userdata
    os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
    from getpass import getpass
    os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))

import anthropic

client = anthropic.Anthropic()

resp = client.beta.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    betas=["mcp-client-2025-11-20"],
    mcp_servers=[{
        "type": "url",
        "url": "https://orders.example.com/mcp",
        "name": "orders",
    }],
    tools=[{"type": "mcp_toolset", "mcp_server_name": "orders"}],
    messages=[{"role": "user",
               "content": "Any open orders mentioning late delivery?"}],
)
print(resp.content)
Anthropic's MCP connector is a beta on the Messages API: declare the remote server in mcp_servers, then expose its tools to the model with an mcp_toolset tool entry. Anthropic's infrastructure runs the MCP client — handshake, tools/list, tools/call — during the request; your code never speaks the protocol.

Everything from the rest of this lesson still applies — the provider's connector is just another remote MCP client, so your server still needs auth (both vendors let you forward an authorization token for the server), TLS, and the same trust reasoning as any other multi-client deployment. What the connector removes is client plumbing, not responsibility.

Testing an MCP server

The client you just wrote is more than a demo — it's the middle layer of a three-layer testing story. Unit tests exercise the tool logic as plain functions with the real API mocked out: no protocol involved, fast enough to run on every save, verifying the joins, truncation, and error branches. Protocol integration tests drive the server the way a host would — spawn it over stdio with a ClientSession, initialize, list_tools, then call each tool and assert on the returned text (including the error strings: 'Ambiguous: 3 customers match' is part of your contract) — this layer catches what unit tests can't see: schema generation from type hints, serialization, stdout pollution. Finally, an adversarial pass puts a person (or a model) on the client side actively trying to break the server: nonexistent IDs, expired auth, queries that match everything, destructive calls without confirm. Unit tests prove the logic, integration tests prove the wire, and the adversarial pass proves the design.

Whiteboard drills

Check yourself
Drill: "A personal GitHub-integration MCP server currently runs over stdio on your laptop. Product wants to make it a shared team tool. Walk me through everything that has to change — not just the transport flag."
Check yourself
Drill: "Give me a scenario where stdio is the better engineering choice even though the team has the infrastructure to run everything as a shared HTTP service."
Key takeaways
  • A client = transport + ClientSession: initialize → list_tools → call_tool. The SDK is a typed veneer over Lesson 1's JSON.
  • Your integration tests ARE a client: spawn the server via stdio, inject env, assert on results.
  • stdio: local, single-user, spawned subprocess, zero deploy. Streamable HTTP: remote, multi-client, needs auth + TLS + ops.
  • Transport is orthogonal to capabilities — same decorated functions serve both.
  • Remote servers must authenticate clients (OAuth-based flow in the spec / bearer tokens at minimum) and scope their own credentials tightly.
  • OAuth authenticates the client via a bearer token, not each tool call — an overscoped or leaked shared token gives full, unattributable tool access.
  • Prefer per-client tokens over one shared static token: revocation and caller attribution both depend on it.
  • Both major providers can attach a remote MCP server server-side — OpenAI via a {"type": "mcp"} tool entry, Anthropic via mcp_servers + an mcp_toolset tool (beta) — while the server itself stays identical.