Skip to content

mcp: OAuth on *.localhost regressed after the MCP client upgrade in 2.0.4 #54245

Description

@mcostasilva

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:

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).

Expected behavior: HTTP token endpoints on .localhost subdomains receive the same exception as bare localhost. Non-loopback HTTP endpoints remain blocked.

RFC 6761 §6.3 reserves .localhost subdomains for loopback use. Replacing mcp.localhost with localhost is not an equivalent workaround: the application selects its MCP router through the request’s Host header.

Steps to reproduce

  1. Run an OAuth-enabled MCP server at http://mcp.localhost:18259.
  2. Have its OAuth discovery document advertise http://mcp.localhost:18259/token.
  3. Configure OpenCode:
{
  "mcp": {
    "servers": {
      "local": {
        "type": "remote",
        "url": "http://mcp.localhost:18259"
      }
    }
  }
}
  1. Authenticate through /mcps.
  2. The token exchange fails with the error above.

Regression and cause

OpenCode PR #48937, first included in 2.0.4, replaced @modelcontextprotocol/sdk@1.29.0 with @modelcontextprotocol/client@2.0.0.

The previous SDK did not enforce this token-endpoint TLS check. Client 2.0.0 added the check but exempted only these exact hostnames:

hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "[::1]" ||
hostname === "::1"

Consequently, it rejects mcp.localhost.

OpenCode 2.0.26 still pins MCP client 2.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.3 and 2.0.4 was verified.

Suggested fix

Update the bundled MCP client to a release containing support for .localhost subdomains, 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

  • Installed CLI: 2.0.26
  • Running background service: 2.0.24
  • Both releases pin MCP client 2.0.0

Operating System

Darwin 25.6.0, arm64.

Terminal

Herdr, TERM=xterm-256color, COLORTERM=truecolor.

Shell: fish.

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm this.

    I ran a mock OAuth-protected MCP server at http://mcp.localhost:18259, with its metadata giving http://mcp.localhost:18259/token as the token endpoint, and configured it as a remote MCP server. Then I ran opencode mcp auth local and 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 names localhost, 127.0.0.1 and ::1. The script below reproduces it. It needs mcp.localhost to 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.sh

  2. argszero commented on Oct 10, 2026

    @argszero
    Contributor

    I'd like to work on this.

    The pinned @modelcontextprotocol/client@2.0.0 is 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 .localhost as loopback, while still refusing non-loopback http: 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.

  3. argszero commented on Oct 10, 2026

    @argszero
    Contributor

    Implemented in #54345.

    The existing @modelcontextprotocol/client@2.0.0 OAuth patch now accepts the whole .localhost namespace as loopback, per RFC 6761 §6.3 — the same predicate upstream added in typescript-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions