Skip to content

Client transports

Every Client talks to its server over a transport: the thing that actually carries the messages.

You never configure one separately. Client takes a single positional argument and works the transport out from its type.

The server side of each (what mcp.run() does and what you deploy) is Running your server.

Streamable HTTP

Pass a URL string and you get Streamable HTTP, the transport you deploy behind and the one to reach for first:

client.py
from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        result = await client.list_tools()
        print([tool.name for tool in result.tools])

That is the whole production client. Client wraps the URL in streamable_http_client(...) for you, on top of an httpx2.AsyncClient configured the way MCP needs: a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.

Check

A Client you have constructed is not connected. Construction only picks the transport; async with is what opens it. Reach for the connection before entering and the SDK tells you so:

RuntimeError: Client must be used within an async context manager

Nothing was resolved, fetched or spawned when you wrote Client("http://..."). That line is free.

Bring your own httpx2.AsyncClient

The moment you need an Authorization header, a cookie, a proxy, mTLS, or a different timeout, build the httpx2.AsyncClient yourself and hand it to streamable_http_client:

client.py
import httpx2

from mcp import Client
from mcp.client.streamable_http import streamable_http_client


async def main() -> None:
    async with httpx2.AsyncClient(
        headers={"Authorization": "Bearer ..."},
        timeout=httpx2.Timeout(30.0, read=300.0),
    ) as http_client:
        transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
        async with Client(transport) as client:
            result = await client.list_tools()
            print([tool.name for tool in result.tools])

Two things to notice:

  • You own the httpx2.AsyncClient, so you enter and exit it. The SDK never closes a client it didn't create.
  • streamable_http_client(url, http_client=...) returns a transport, and Client(transport) accepts it like anything else.

One TLS note: httpx2 verifies certificates against the operating system trust store (via truststore), not a bundled CA list. In an environment with no usable system CA store (some minimal containers), set the standard SSL_CERT_FILE/SSL_CERT_DIR environment variables or pass an explicit verify=ssl_context to your httpx2.AsyncClient (background in httpx and httpx-sse replaced by httpx2).

Warning

streamable_http_client used to take headers= and timeout= directly. It does not any more: its only parameters are url, http_client and terminate_on_close. Reach for headers= out of habit and you get:

TypeError: streamable_http_client() got an unexpected keyword argument 'headers'

Everything HTTP-shaped now lives on the one httpx2.AsyncClient you pass in.

Info

httpx2 keeps the familiar httpx API, so if you know httpx you already know how to do auth, proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes nothing away, except redirect handling. It is also where OAuth plugs in: httpx2.AsyncClient(auth=OAuthClientProvider(...)). That whole flow is OAuth clients.

Redirects

The transport connects to the URL you gave it, and only that origin.

  • A 307/308 redirect that stays on the same scheme, host and port is followed, and so is http://https:// on the same host. That covers the usual /mcp/mcp/ trailing-slash redirect.
  • A redirect anywhere else is not followed. The call fails with:

    MCPError: Redirect to https://other.example.com/mcp not followed; use that URL as the endpoint if it is the intended server
    

    If that URL is the server you meant, put it in your config. If it isn't, the server or a proxy in front of it is misconfigured.

This holds for any httpx2.AsyncClient you pass in: its follow_redirects setting is not consulted for MCP requests, in either direction. The SDK's OAuth providers apply the same rule to their own requests.

Tip

Redirect to http://… not followed: it would downgrade this HTTPS endpoint to plain HTTP means the server sits behind a TLS-terminating proxy it doesn't know about and is issuing http:// redirects. That is fixed on the server (Deploy & scale), or by using the exact https://…/ URL the message suggests.

stdio

A stdio server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host is this code plus a UI, and Connect to a real host is the same relationship seen from the host's side, as a config file.

Describe the process with StdioServerParameters and hand it to Client:

client.py
from mcp import Client, StdioServerParameters

server = StdioServerParameters(
    command="uv",
    args=["run", "server.py"],
    env={"BOOKSHOP_API_KEY": "secret"},
)


async def main() -> None:
    async with Client(server) as client:
        result = await client.list_tools()
        print([tool.name for tool in result.tools])

Entering the block spawns the process. Leaving it shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.

The child's stderr goes to yours. To send it somewhere else, build the transport yourself with stdio_client (from mcp) and pass that instead: Client(stdio_client(server, errlog=log_file)).

Warning

The child does not inherit your environment. It gets a minimal allow-list (HOME, LOGNAME, PATH, SHELL, TERM and USER on POSIX) so nothing sensitive leaks into a process you may not have written.

A server that needs an API key won't find it there. Pass it explicitly with env=; those variables are merged on top of the allow-list. That is what BOOKSHOP_API_KEY is doing above.

In memory

In a test there is nothing to deploy and nothing to launch. Pass the server object itself:

from mcp import Client
from mcp.server import MCPServer

mcp = MCPServer("Bookshop")


@mcp.tool()
def search_books(query: str) -> str:
    """Search the catalog by title or author."""
    return f"Found 3 books matching {query!r}."


async def main() -> None:
    async with Client(mcp) as client:
        result = await client.call_tool("search_books", {"query": "dune"})
        print(result.structured_content)

No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: search_books is listed, validated and invoked exactly as it would be over HTTP. Testing builds the whole pattern around it.

The same form doubles as an embedding API: an application that constructs the server itself can call its tools without a network hop.

SSE

sse_client(url), from mcp.client.sse, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, Client(sse_client("http://localhost:8000/sse")), to talk to a server that still speaks it, and don't build anything new on it.

The Transport protocol

To Client, all of the above are the same thing.

A transport is any async context manager that yields a (read, write) pair of message streams: formally, the Transport protocol in mcp.client. Client resolves its argument by type: a str becomes streamable_http_client(url), a StdioServerParameters becomes stdio_client(params), a server object connects in-process, and anything else is entered as a transport directly. That last rule is why stdio_client(...), streamable_http_client(...) and sse_client(...) all drop into the same slot, and why you can write your own.

Recap

  • Client("http://.../mcp") (a URL) connects over Streamable HTTP, the production transport.
  • Headers, auth, proxies and timeouts belong on an httpx2.AsyncClient you pass to streamable_http_client(url, http_client=...). There is no headers= keyword.
  • Redirects are followed only within the URL's own origin (a trailing-slash 307/308), plus httphttps on the same host. Anything else fails with Redirect to … not followed; configure the final URL.
  • stdio is Client(StdioServerParameters(...)). Wrap it in stdio_client(...) yourself only to redirect the child's stderr.
  • The subprocess gets an allow-listed environment, not yours; env= adds to it.
  • Client(mcp) (the server object) connects in memory. Use it in tests, or to embed a server in the application that built it.
  • A transport is anything you can async with x as (read, write). Client hands anything that isn't a server object, a URL or StdioServerParameters straight to that protocol.
  • Constructing a Client picks the transport. async with opens it.

Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, Protocol versions is the page.