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.
# 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())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
| stdio | Streamable HTTP | |
|---|---|---|
| Topology | Client spawns server as a local subprocess; pipes are the wire | Server is an independent HTTP service; clients connect over the network |
| Users | One client, one server instance, one machine | Many concurrent clients, sessions multiplexed |
| Auth | Inherits local trust — whoever can run the process | Required: token/OAuth-based auth at the HTTP layer |
| Ops | Nothing to deploy; dies with the client | Deploy, monitor, scale like any web service; supports streaming responses and resumable sessions |
| Fits | Personal dev tools, Claude Desktop/Code local servers, Lab 06's default | Team-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.
# 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 clientOAuth 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.
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.
# 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)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
- ▸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 viamcp_servers+ anmcp_toolsettool (beta) — while the server itself stays identical.