Claude Code (macOS) cannot resolve .local MCP/marketplace hostnames — bundled resolver short-circuits .local before any DNS lookup
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:
- Live probe, not cached / not a timeout.
claude mcp listre-resolves each run. The.localattempt fails in ~4 ms against a 30,000 ms connection timeout, withConnectionRefused; 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. - 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.localprobe shows no packets for the name — no unicast to the resolver, no mDNS multicast — while agetaddrinfocaller resolving a sibling.localname in the same window emits both.BUN_CONFIG_VERBOSE_FETCH=curl claude mcp listprints fetch lines for everylocalhost/IP target and never for any.localhost (the request is never issued). - Unicast-resolvable
.localstill fails (no capture needed). For a split-horizon.localname whose plain unicast nameserver has an A record (dig @<resolver> host.corp.localreturns IPs, andnode/curlconnect), 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. - Scoped
/etc/resolverignored. With/etc/resolver/<suffix>pointing at a local logging DNS server: thegetaddrinfocontrol's query reaches the logger; Claude Code's probe sends it nothing. - 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=1makes no difference. - Stock Bun is fine.
npm i bun@1.3.14thenbunfetch/Bun.dns.lookupagainst a.localhost resolves via the system resolver without issue. So this is not upstream Bun behavior — it is specific to the bundled build (63bb0ca0d, absent fromoven-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:
.localhostnames resolve the way the OS resolves them — via mDNS and/or the configured unicast resolver (ascurl/node/stock Bun do). - Actual: Claude Code emits no DNS for
.localnames 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'sgetaddrinfo; all.localMCP servers connect, no per-host config. Cleanest for split-horizon corp domains.- Add the host to
/etc/hosts(read before the.localshort-circuit). - Use a literal IP (IPv4 or
[::1]/IPv6 literal), or a non-.localhostname. - 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.