Claude Code (macOS) cannot resolve .local MCP/marketplace hostnames — bundled resolver short-circuits .local before any DNS lookup

Resolved 💬 0 comments Opened Jul 6, 2026 by adobrynin-at-smartcat Closed Jul 7, 2026

Summary

On macOS, Claude Code fails to resolve any HTTP/SSE MCP server (or plugin marketplace) hostname ending in .local, even though the OS resolver (getaddrinfo) resolves the same name successfully at the same moment. Claude Code performs no DNS for the name at all — no unicast query to the configured resolver, no mDNS multicast — and fails in a few milliseconds (well under the connection timeout). curl, node, and dscacheutil resolve the identical name on the same machine.

This is specific to the Bun build Claude Code bundles, not upstream Bun: the embedded runtime reports Bun v1.4.0 (63bb0ca0d), a commit that does not exist in oven-sh/bun, and no public Bun 1.4.0 has been released. Stock Bun 1.3.14 resolves .local via the system resolver without issue.

Environment

  • Claude Code v2.1.201 (native installer), binary sha256 a0852d76afc47b30f5cb0b7625ec9a7714cb189f2eeef6c28c77e2be954fb7fd
  • Embedded runtime string: Bun v1.4.0 (63bb0ca0d) macOS arm64 (commit not present upstream; likely a patched/forked build)
  • macOS 15 (Darwin 25.5.0), arm64
  • Transport: HTTP (streamable) MCP servers; also affects the plugin marketplace fetch

Impact

Any MCP server — or plugin marketplace — hosted under a .local domain is unreachable from Claude Code on macOS. This hits corporate split-horizon / VPN setups that use a .local internal domain resolved via a unicast nameserver, and it also affects ordinary Bonjour/mDNS .local names. Because curl/node on the same machine work, it presents as "Claude-only."

Minimal reproducer (self-contained — any Mac, no VPN, no custom DNS)

Uses the machine's own Bonjour .local name (macOS resolves it via mDNS) and a real minimal MCP server so the pass/fail is unambiguous (Connected via IP vs Failed via .local, same server).

# 1) tiny MCP server that answers initialize/tools/list
cat > /tmp/mini-mcp.js <<'JS'
const http = require('http');
http.createServer((req, res) => {
  let b = ''; req.on('data', c => b += c);
  req.on('end', () => {
    let m = {}; try { m = JSON.parse(b); } catch {}
    const ok = r => { res.writeHead(200, {'Content-Type':'application/json'});
                      res.end(JSON.stringify({jsonrpc:'2.0', id:m.id, result:r})); };
    if (m.method === 'initialize')  return ok({protocolVersion:'2025-06-18', capabilities:{tools:{}}, serverInfo:{name:'repro', version:'0'}});
    if (m.method === 'tools/list')  return ok({tools:[]});
    res.writeHead(202); res.end();
  });
}).listen(7788, '0.0.0.0', () => console.error('mini-mcp on :7788'));
JS
node /tmp/mini-mcp.js &

HN="$(scutil --get LocalHostName).local"          # e.g. yourmac.local
LANIP="$(ipconfig getifaddr en0)"

# 2) OS resolves the .local name fine, and the server is reachable via it:
dscacheutil -q host -a name "$HN"                  # returns your LAN IP
curl -sS -o /dev/null -w "curl -> %{http_code} @ %{remote_ip}\n" "http://$HN:7788/"

# 3) Same server, two Claude Code MCP entries — literal IP vs the .local name:
claude mcp add --transport http repro_ip    "http://$LANIP:7788/mcp"
claude mcp add --transport http repro_local "http://$HN:7788/mcp"
claude mcp list | grep -E 'repro_ip|repro_local'
#   repro_ip:    ... ✔ Connected           <- resolves + connects
#   repro_local: ... ✘ Failed to connect   <- identical server, .local name

# cleanup
claude mcp remove repro_ip; claude mcp remove repro_local
kill %1 2>/dev/null; rm -f /tmp/mini-mcp.js

Observed: the literal-IP entry connects; the .local entry to the same server fails. Server access log shows the .local probe makes zero TCP connections (the request is never sent), while the IP probe connects.

Diagnosis / evidence

The failure is purely name resolution, and it happens before any network I/O:

  1. Live probe, not cached / not a timeout. claude mcp list re-resolves each run. The .local attempt fails in ~4 ms against a 30,000 ms connection timeout, with ConnectionRefused; MCP debug log: HTTP Connection failed after 4ms: Unable to connect ... (code: ConnectionRefused). Each invocation is a fresh process, so it is not an in-process negative cache.
  2. No DNS on the wire. With caches flushed (sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder), tcpdump (-ni any 'port 53 or port 5353') during a live .local probe shows no packets for the name — no unicast to the resolver, no mDNS multicast — while a getaddrinfo caller resolving a sibling .local name in the same window emits both. BUN_CONFIG_VERBOSE_FETCH=curl claude mcp list prints fetch lines for every localhost/IP target and never for any .local host (the request is never issued).
  3. Unicast-resolvable .local still fails (no capture needed). For a split-horizon .local name whose plain unicast nameserver has an A record (dig @<resolver> host.corp.local returns IPs, and node/curl connect), Claude Code still fails it in milliseconds. If it did any ordinary unicast DNS it would have resolved this — so it performs none for .local.
  4. Scoped /etc/resolver ignored. With /etc/resolver/<suffix> pointing at a local logging DNS server: the getaddrinfo control's query reaches the logger; Claude Code's probe sends it nothing.
  5. Not the DNS-cache flags. Launching with BUN_FEATURE_FLAG_DISABLE_DNS_CACHE=1, ..._DISABLE_DNS_CACHE_LIBINFO=1, BUN_CONFIG_DNS_TIME_TO_LIVE_SECONDS=0, ..._DISABLE_ADDRCONFIG=1, ..._DISABLE_IPV6=1 makes no difference.
  6. Stock Bun is fine. npm i bun@1.3.14 then bun fetch/Bun.dns.lookup against a .local host resolves via the system resolver without issue. So this is not upstream Bun behavior — it is specific to the bundled build (63bb0ca0d, absent from oven-sh/bun).

Conclusion: Claude Code's bundled resolver classifies .local and fails it before any resolver lookup — it consults /etc/hosts, and on a miss fails immediately with no mDNS query and no unicast/getaddrinfo fallback.

Expected vs actual

  • Expected: .local hostnames resolve the way the OS resolves them — via mDNS and/or the configured unicast resolver (as curl/node/stock Bun do).
  • Actual: Claude Code emits no DNS for .local names and fails, unless the name is in /etc/hosts.

Workarounds (for affected users)

  • HTTP_PROXY/http_proxy → a local forward proxy (e.g. HTTP_PROXY=http://127.0.0.1:<port> claude ...): resolution moves to the proxy's getaddrinfo; all .local MCP servers connect, no per-host config. Cleanest for split-horizon corp domains.
  • Add the host to /etc/hosts (read before the .local short-circuit).
  • Use a literal IP (IPv4 or [::1]/IPv6 literal), or a non-.local hostname.
  • Wrap the server as a stdio subprocess that resolves via Node, e.g. npx mcp-remote http://host.example.local/mcp.

No Bun env var / bunfig / DNS-override was found that forces the macOS resolver path for the HTTP client.

Likely cause / suggested fix

The bundled Bun build's native fetch/DNS path appears to treat .local as mDNS-reserved (RFC 6762) but never performs the mDNS query, and provides no unicast/getaddrinfo fallback — so .local is unresolvable except via /etc/hosts. Suggested fix: for .local, either perform the mDNS query or fall back to the OS getaddrinfo path (which handles both mDNS and split-horizon unicast .local via SystemConfiguration), matching stock Bun and curl. Stock c-ares special-cases .localhost/.onion but not .local, so this looks specific to the bundled Bun's resolver layer.

Notes on reproducibility across machines

One user on 2.1.201/arm64 hits this; another on the same claude --version reportedly does not. Since a given claude --version can bundle different Bun builds across arch/channel, comparing this between an affected and an unaffected machine will localize it:

CC="$(command -v claude)"; CC="$(readlink -f "$CC" 2>/dev/null || echo "$CC")"
claude --version; file "$CC"; shasum -a 256 "$CC"
grep -aoE 'Bun v[0-9]+\.[0-9]+\.[0-9]+ \([0-9a-f]+\)' "$CC" | sort -u

A differing sha256 / embedded-Bun line on the unaffected machine points to a build/Bun-version delta.

View original on GitHub ↗