Repository navigation
mcp: OAuth on *.localhost regressed after the MCP client upgrade in 2.0.4 #54245
Description
Activity
opencode-agent commented
on Oct 10, 2026 on Oct 10, 2026 – with OpenCode AgentContributorMore actionsThanks for the detailed report. I can confirm this.
I ran a mock OAuth-protected MCP server at
http://mcp.localhost:18259, with its metadata givinghttp://mcp.localhost:18259/tokenas the token endpoint, and configured it as a remote MCP server. Then I ranopencode mcp auth localand completed the authorize redirect. On 2.0.26, discovery, client registration and authorization all succeed. The code exchange then fails with the same error you saw:Refusing to send credentials to non-https token endpoint 'http://mcp.localhost:18259/token'. OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).It still happens on the latest
v2(55dde88). That branch still pins@modelcontextprotocol/client@2.0.0, and that version's loopback check only allows the exact nameslocalhost,127.0.0.1and::1. The script below reproduces it. It needsmcp.localhostto resolve to 127.0.0.1, for example through/etc/hosts.Reproduction script (repro.sh)
#!/usr/bin/env bash # Reproduces anomalyco/opencode#54245: OAuth token exchange refused for http://mcp.localhost. # Requires: bun, curl, `opencode` on PATH (or OC_DEV=/path/to/checkout), and # mcp.localhost resolving to 127.0.0.1 (e.g. `echo "127.0.0.1 mcp.localhost" >> /etc/hosts`). set -u DIR="$(cd "$(dirname "$0")" && pwd)" # Start a mock OAuth-protected MCP server on 127.0.0.1:18259 that advertises http://mcp.localhost:18259/token SRV="$(mktemp -d)/server.ts" cat > "$SRV" <<'TS' // Minimal OAuth-protected "MCP" server on http://mcp.localhost:18259 // (needs `127.0.0.1 mcp.localhost` resolvable, e.g. via /etc/hosts). const BASE = "http://mcp.localhost:18259" const json = (o: unknown, status = 200, headers: Record<string, string> = {}) => new Response(JSON.stringify(o), { status, headers: { "content-type": "application/json", ...headers } }) Bun.serve({ port: 18259, hostname: "127.0.0.1", async fetch(req) { const url = new URL(req.url) console.log(req.method, url.pathname, "Host:", req.headers.get("host")) if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) return json({ resource: BASE + "/", authorization_servers: [BASE] }) if (url.pathname.startsWith("/.well-known/oauth-authorization-server") || url.pathname.startsWith("/.well-known/openid-configuration")) return json({ issuer: BASE, authorization_endpoint: BASE + "/authorize", token_endpoint: BASE + "/token", registration_endpoint: BASE + "/register", response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], }) if (url.pathname === "/register") { const body = (await req.json()) as any return json({ ...body, client_id: "test-client", token_endpoint_auth_method: "none" }, 201) } if (url.pathname === "/authorize") { const r = new URL(url.searchParams.get("redirect_uri")!) r.searchParams.set("code", "test-code") if (url.searchParams.get("state")) r.searchParams.set("state", url.searchParams.get("state")!) return Response.redirect(r.toString(), 302) } if (url.pathname === "/token") { console.log("TOKEN ENDPOINT HIT") return json({ access_token: "tok", token_type: "Bearer", expires_in: 3600 }) } if (req.headers.get("authorization") !== "Bearer tok") return new Response("unauthorized", { status: 401, headers: { "www-authenticate": `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"` }, }) return new Response("ok") }, }) console.log("listening", BASE) TS bun "$SRV" > /tmp/oc-54245-server.log 2>&1 & SRV_PID=$! trap 'kill $SRV_PID 2>/dev/null' EXIT sleep 1 export HOME="${OC_HOME:-/tmp/oc-54245-home}"; mkdir -p "$HOME" # stop any opencode background service left over from a previous run pkill -f "opencode.*serv" 2>/dev/null; sleep 1 PROJ="$(mktemp -d)" cat > "$PROJ/opencode.json" <<'EOF' { "mcp": { "servers": { "local": { "type": "remote", "url": "http://mcp.localhost:18259" } } } } EOF cd "$PROJ" if [ -n "${OC_DEV:-}" ]; then # the managed background service is spawned as a child bun process; pass the same flags to it export BUN_OPTIONS="--conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" OC=(bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts") else OC=(opencode) fi "${OC[@]}" --version OUT="$(mktemp)" timeout 60 "${OC[@]}" mcp auth local >"$OUT" 2>&1 & PID=$! # Act as the browser: wait for the authorize URL, then follow it to the local callback. for i in $(seq 1 60); do URL="$(grep -o 'http://mcp.localhost:18259/authorize[^ ]*' "$OUT" | head -1)" [ -n "$URL" ] && break sleep 0.5 done echo "authorize URL: $URL" curl -s -L -o /dev/null -w "browser: followed redirect to %{url_effective} (HTTP %{http_code})\n" "$URL" wait $PID echo "token endpoint requests received by server: $(grep -c "TOKEN ENDPOINT HIT" /tmp/oc-54245-server.log)" echo "----- opencode mcp auth output -----" sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' "$OUT"
Run it:
bash repro.shI'd like to work on this.
The pinned
@modelcontextprotocol/client@2.0.0is already patched in this repo for OAuth behavior, so the smallest fix is to extend that patch with the rule typescript-sdk#2597 shipped: treat a hostname ending in.localhostas loopback, while still refusing non-loopbackhttp:token endpoints. I have that change plus a regression test covering both halves running locally.Happy to bump the bundled client instead if you would rather take a newer release.
Implemented in #54345.
The existing
@modelcontextprotocol/client@2.0.0OAuth patch now accepts the whole.localhostnamespace as loopback, per RFC 6761 §6.3 — the same predicate upstream added intypescript-sdk#2597(shipped in client 2.3.1). The patch was regenerated mechanically rather than hand-edited, so the two pre-existing hunks (url.search = issuer.search,prompt=consent) are byte-identical and the header blob hashes remain the real 40-character ones.
Description
OpenCode can no longer complete OAuth authentication against a local MCP server at
http://mcp.localhost:18259. This setup worked before.The token exchange fails with:
Expected behavior: HTTP token endpoints on
.localhostsubdomains receive the same exception as barelocalhost. Non-loopback HTTP endpoints remain blocked.RFC 6761 §6.3 reserves
.localhostsubdomains for loopback use. Replacingmcp.localhostwithlocalhostis not an equivalent workaround: the application selects its MCP router through the request’sHostheader.Steps to reproduce
http://mcp.localhost:18259.http://mcp.localhost:18259/token.{ "mcp": { "servers": { "local": { "type": "remote", "url": "http://mcp.localhost:18259" } } } }/mcps.Regression and cause
OpenCode PR #48937, first included in
2.0.4, replaced@modelcontextprotocol/sdk@1.29.0with@modelcontextprotocol/client@2.0.0.The previous SDK did not enforce this token-endpoint TLS check. Client
2.0.0added the check but exempted only these exact hostnames:Consequently, it rejects
mcp.localhost.OpenCode
2.0.26still pins MCP client2.0.0. Its compiled binary also contains this exact-host check.The last working OpenCode version in this setup was not recorded. The dependency change between
2.0.3and2.0.4was verified.Suggested fix
Update the bundled MCP client to a release containing support for
.localhostsubdomains, or apply the same hostname change locally.The MCP SDK already fixed this behavior in PR #2597, merged September 23, 2026. It adds
hostname.endsWith(".localhost")to the loopback check while preserving the HTTPS requirement for non-loopback hosts.The matching SDK report is issue #2591.
OpenCode version
2.0.262.0.242.0.0Operating System
Darwin
25.6.0, arm64.Terminal
Herdr,
TERM=xterm-256color,COLORTERM=truecolor.Shell: fish.