Repository navigation
The IP-pinned transports bind httpx, while the MCP client path requires httpx2 #1363
Description
What is wrong
adcp.signing ships the DNS-rebinding pin for one httpx generation only.
IpPinnedTransport subclasses httpx.HTTPTransport and AsyncIpPinnedTransport
subclasses httpx.AsyncHTTPTransport. The MCP client path requires the other
generation: mcp>=2.0 runs on httpx2, MCPAdapter(httpx_client_factory=...) is
typed against mcp.shared._httpx_utils.McpHttpClientFactory, which returns an
httpx2.AsyncClient, and adcp/protocols/mcp.py enforces it —
httpx_client_factory must return an MCP-compatible client exposing sse();
MCP SDK v2 uses httpx2, so a plain httpx.AsyncClient is not compatible
So an adopter who wants the pin on the MCP path has nothing to use. httpx2 and
httpcore2 are separate distributions from httpx and httpcore, and
adcp's own dependencies install both pairs:
adcp requires: httpx<1.0,>=0.24.0 httpcore<2.0,>=1.0 mcp<3.0,>=2.0.0
mcp 2.2.0 requires: httpx2>=2.5.0 (which brings httpcore2)
installed: httpx 0.28.1 + httpcore 1.0.9 and httpx2 2.12.0 + httpcore2 2.12.0
issubclass(httpx.AsyncClient, httpx2.AsyncClient) -> False
Layer
API DESIGN — a dependency pin plus a missing transport variant. The pin
mechanism itself is generation-independent: it lives in an httpcore network
backend that overrides connect_tcp to swap hostname for pre-resolved IP, and
both generations expose ConnectionPool(network_backend=...) and connect_tcp
with identical signatures.
Downstream consequence
httpx2 runs no type check on transport=, so the mismatch is accepted at
construction and surfaces on the first request as a bare, message-less
AssertionError raised out of the other library's file. Nothing on the path
says two httpx generations were mixed.
Self-contained reproduction — in-process loopback server, no traffic off the
machine:
"""Run twice: `python repro.py` and `python -O repro.py`."""
import asyncio
import http.server
import threading
import traceback
import httpx
import httpx2
from adcp.signing import AsyncIpPinnedTransport
received = []
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
received.append(self.path)
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, *args):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"httpx {httpx.__version__} | httpx2 {httpx2.__version__} | asserts on: {__debug__}")
async def main():
transport = AsyncIpPinnedTransport(hostname="localhost", resolved_ip="127.0.0.1")
print(f"isinstance httpx2.AsyncBaseTransport: {isinstance(transport, httpx2.AsyncBaseTransport)}")
async with httpx2.AsyncClient(transport=transport) as client:
print("httpx2.AsyncClient accepted it")
try:
response = await client.get(f"http://localhost:{port}/pinned")
print(f"request returned {response.status_code}")
except Exception as exc:
print(f"request raised {type(exc).__name__} with message {str(exc)!r}")
print(" " + traceback.format_exc().strip().splitlines()[-3].strip())
print(f"requests the server actually received: {received}")
asyncio.run(main())
server.shutdown()Output:
$ python repro.py
httpx 0.28.1 | httpx2 2.12.0 | asserts on: True
isinstance httpx2.AsyncBaseTransport: False
httpx2.AsyncClient accepted it
request raised AssertionError with message ''
assert isinstance(request.stream, AsyncByteStream)
requests the server actually received: []
$ python -O repro.py
httpx 0.28.1 | httpx2 2.12.0 | asserts on: False
isinstance httpx2.AsyncBaseTransport: False
httpx2.AsyncClient accepted it
request raised RuntimeError with message 'Attempted to call an async close on an sync stream.'
File ".../httpx/_models.py", line 1071, in aclose
requests the server actually received: ['/pinned']
Two things in that output decide the severity.
The only diagnostic is an assertion, and python -O removes it. With
assertions on, the mismatch is fail-closed: httpx/_transports/default.py:378
(:234 on the sync path) stops the call and the loopback server records zero
requests. Under -O the assertion is gone, the request reaches the network, the
server records it, and the caller sees RuntimeError: Attempted to call an async close on an sync stream. — a message about stream closing, for a dependency
mismatch, after the request already went out. A non-idempotent call made and
then reported as failed is a duplicate delivery on retry.
The pin's own refusal survives -O, because it raises rather than asserts:
reusing a pinned transport for another host still gives
RuntimeError: AsyncIpPinnedTransport is pinned to 'localhost'; refusing connect to '127.0.0.1' with zero requests received. The pin is sound. What is missing is
a variant for the generation the MCP path uses, and a diagnostic for the
mismatch.
The static picture is the same. An adopter writing the MCP factory with the one
pinned transport the SDK ships:
def pinned_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
return httpx2.AsyncClient(
transport=build_async_ip_pinned_transport(SELLER),
headers=headers, timeout=timeout, auth=auth,
follow_redirects=False, trust_env=False,
)
factory: McpHttpClientFactory = pinned_client_factory$ mypy --strict adopter.py
adopter.py:18:19: error: Argument "transport" to "AsyncClient" has incompatible type
"AsyncIpPinnedTransport"; expected "AsyncBaseTransport | None" [arg-type]
There is no spelling of that factory that both satisfies McpHttpClientFactory
and pins the connection.
Fix
Ship IpPinnedTransport2 / AsyncIpPinnedTransport2 and
build_ip_pinned_transport2 / build_async_ip_pinned_transport2 on httpcore2,
and refuse an httpx-generation transport at the MCP factory's existing
duck-type check with a message naming the split.
Activity
bokelley commentedon Oct 3, 2026
Triage
Classification: Bug
Bucket(s): signing (label not present in repo — gap noted)
Status: deferred
Duplicate/open-PR gate:
PR #1378 ("fix(signing): ship the IP pin for httpx2 as well as httpx") already references this issue with
Closes #1363and covers the full fix scope:IpPinnedTransport2/AsyncIpPinnedTransport2plus the generation-mismatch guard in_make_custom_mcp_http_factory. No further triage work needed here.Blocked-on: #1378 — resurfaces on merge.
Triaged by Claude Code. Session: https://claude.ai/code/session_012n2y55sFdzAf4MBoFRZv76
Generated by Claude Code