|
| 1 | +"""HTTP destination checks usable without Django inside an MCP worker.""" |
| 2 | + |
| 3 | +import ipaddress |
| 4 | +import socket |
| 5 | +import ssl |
| 6 | + |
| 7 | +import anyio |
| 8 | +import httpcore |
| 9 | +import httpx |
| 10 | + |
| 11 | + |
| 12 | +class MCPNetworkPolicyError(ValueError): |
| 13 | + """Locally generated, credential-free policy failure safe to report.""" |
| 14 | + |
| 15 | + |
| 16 | +def sandbox_failure_message(error): |
| 17 | + # SDK exception groups and chained HTTP errors can embed credentials. Only |
| 18 | + # report our own policy text, a numeric HTTP status, or a fixed description. |
| 19 | + errors, pending, seen = [], [error], set() |
| 20 | + while pending: |
| 21 | + current = pending.pop() |
| 22 | + if id(current) in seen: |
| 23 | + continue |
| 24 | + seen.add(id(current)) |
| 25 | + errors.append(current) |
| 26 | + if isinstance(current, BaseExceptionGroup): |
| 27 | + pending.extend(current.exceptions) |
| 28 | + if current.__cause__ is not None: |
| 29 | + pending.append(current.__cause__) |
| 30 | + for current in errors: |
| 31 | + if isinstance(current, MCPNetworkPolicyError): |
| 32 | + return str(current) |
| 33 | + for current in errors: |
| 34 | + if isinstance(current, httpx.HTTPStatusError): |
| 35 | + status = current.response.status_code |
| 36 | + if 300 <= status < 400: |
| 37 | + return "MCP endpoint returned a redirect; configure its final URL" |
| 38 | + return f"MCP endpoint returned HTTP {status}; check endpoint and credentials" |
| 39 | + for exception_type, message in ( |
| 40 | + (ssl.SSLCertVerificationError, "MCP TLS certificate verification failed"), |
| 41 | + (socket.gaierror, "MCP hostname resolution failed; check container DNS"), |
| 42 | + (PermissionError, "MCP access denied; check sandbox file and network policy"), |
| 43 | + ((httpx.TimeoutException, TimeoutError), "MCP connection timed out"), |
| 44 | + (httpx.ConnectError, "MCP connection failed; check container connectivity and sandbox network policy"), |
| 45 | + ): |
| 46 | + if any(isinstance(current, exception_type) for current in errors): |
| 47 | + return message |
| 48 | + return "MCP session failed; check endpoint, sandbox setup and network policy" |
| 49 | + |
| 50 | + |
| 51 | +def parse_url(value): |
| 52 | + if not isinstance(value, str) or not value or any(ord(c) <= 32 for c in value): |
| 53 | + raise ValueError("Invalid MCP server URL") |
| 54 | + try: |
| 55 | + url = httpx.URL(value) |
| 56 | + if ( |
| 57 | + url.scheme not in ("http", "https") or not url.host or url.userinfo |
| 58 | + or url.fragment or "%" in url.host or "\\" in value |
| 59 | + or (url.port is not None and not 1 <= url.port <= 65535) |
| 60 | + ): |
| 61 | + raise ValueError("Invalid MCP server URL") |
| 62 | + return url |
| 63 | + except (httpx.InvalidURL, ValueError) as exc: |
| 64 | + raise ValueError("Invalid MCP server URL") from exc |
| 65 | + |
| 66 | + |
| 67 | +def check_addresses(addresses, networks): |
| 68 | + if not addresses: |
| 69 | + raise MCPNetworkPolicyError("MCP server hostname has no addresses") |
| 70 | + for address in addresses: |
| 71 | + ip = ipaddress.ip_address(address) |
| 72 | + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped: |
| 73 | + ip = ip.ipv4_mapped |
| 74 | + # Do not let IPv6 transition mechanisms tunnel to restricted IPv4 hosts. |
| 75 | + transition = isinstance(ip, ipaddress.IPv6Address) and ( |
| 76 | + ip.sixtofour is not None or ip.teredo is not None |
| 77 | + or ip in ipaddress.ip_network("64:ff9b::/96") |
| 78 | + or ip in ipaddress.ip_network("64:ff9b:1::/48") |
| 79 | + ) |
| 80 | + public = ip.is_global and not ip.is_multicast and not transition |
| 81 | + if not public and not any(ip in network for network in networks): |
| 82 | + raise MCPNetworkPolicyError("MCP server address is not allowed by the network policy") |
| 83 | + |
| 84 | + |
| 85 | +class MCPNetworkBackend(httpcore.AnyIOBackend): |
| 86 | + def __init__(self, networks): |
| 87 | + self.networks = networks |
| 88 | + |
| 89 | + async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): |
| 90 | + try: |
| 91 | + with anyio.fail_after(timeout): |
| 92 | + # Resolve again at connection time, validate EVERY result, then |
| 93 | + # connect to the numeric address. HTTP Host and TLS SNI remain |
| 94 | + # the original hostname in httpcore, including certificate checks. |
| 95 | + results = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM) |
| 96 | + addresses = list(dict.fromkeys(item[4][0] for item in results)) |
| 97 | + check_addresses(addresses, self.networks) |
| 98 | + for index, address in enumerate(addresses): |
| 99 | + try: |
| 100 | + return await super().connect_tcp( |
| 101 | + address, port, timeout, local_address, socket_options |
| 102 | + ) |
| 103 | + except (httpcore.ConnectError, httpcore.ConnectTimeout): |
| 104 | + if index == len(addresses) - 1: |
| 105 | + raise |
| 106 | + except TimeoutError as exc: |
| 107 | + raise httpcore.ConnectTimeout() from exc |
| 108 | + except OSError as exc: |
| 109 | + raise httpcore.ConnectError(str(exc)) from exc |
| 110 | + |
| 111 | + |
| 112 | +class MCPTransport(httpx.AsyncHTTPTransport): |
| 113 | + def __init__(self, url, networks, internal=False): |
| 114 | + super().__init__(trust_env=False) |
| 115 | + self.url = parse_url(url) |
| 116 | + self.internal = internal |
| 117 | + # HTTPX 0.28 has no public network_backend argument. Keep its standard |
| 118 | + # response/error handling and replace only the pool's connection backend. |
| 119 | + self._pool._network_backend = MCPNetworkBackend(networks) |
| 120 | + |
| 121 | + async def handle_async_request(self, request): |
| 122 | + target = parse_url(str(request.url)) |
| 123 | + if (target.scheme, target.host, target.port) != (self.url.scheme, self.url.host, self.url.port): |
| 124 | + raise ValueError("MCP requests must stay on the configured origin") |
| 125 | + if self.internal and target != self.url: |
| 126 | + raise ValueError("Internal MCP requests must use the generated endpoint") |
| 127 | + return await super().handle_async_request(request) |
| 128 | + |
| 129 | + |
| 130 | +def http_client_factory(headers=None, timeout=None, auth=None, *, url, networks, internal=False): |
| 131 | + return httpx.AsyncClient( |
| 132 | + headers=headers, |
| 133 | + timeout=timeout if timeout is not None else httpx.Timeout(30, read=300), |
| 134 | + auth=auth, |
| 135 | + follow_redirects=False, |
| 136 | + trust_env=False, |
| 137 | + transport=MCPTransport(url, networks, internal), |
| 138 | + ) |
0 commit comments