From 3b709a68610b35b96bb21d31c41a496b224211e2 Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:06:31 -0400 Subject: [PATCH 1/2] feat: trigger MCP OAuth for servers that need authentication After query() initialization, check mcpServerStatus() for HTTP/SSE MCP servers in 'needs-auth' state and trigger the Claude Code CLI's built-in OAuth flow via the mcp_authenticate control message. The CLI handles the full PKCE flow: RFC 9728 discovery, dynamic client registration, localhost callback server, token exchange, and keychain storage. The agent opens the user's browser for OAuth consent and polls until the server transitions to 'connected'. Browser opening mirrors the CLI's internal approach: respects $BROWSER, uses rundll32 on Windows, open on macOS, xdg-open on Linux. In headless environments where opening fails, the auth URL is logged as an error and the server is skipped gracefully. Previously, MCP servers requiring OAuth would silently fail to connect unless the ACP client pre-injected static Authorization headers. Validated end-to-end with Datadog and Atlassian MCP servers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/acp-agent.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 52e156a..9b560e0 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -1690,6 +1690,104 @@ export class ClaudeAcpAgent implements Agent { throw error; } + // MCP OAuth: detect servers that need authentication and trigger the + // SDK's built-in OAuth flow. The Claude Code CLI subprocess handles + // the full PKCE flow (RFC 9728 discovery, dynamic client registration, + // localhost callback server, token exchange, keychain storage). + // + // The `mcp_authenticate` control message is an undocumented internal + // API of the Claude Code CLI. It triggers OAuth discovery for the + // named server and returns an `authUrl` for user consent. The CLI + // starts a localhost callback server to receive the authorization code. + if (!creationOpts?.resume && Object.keys(mcpServers).length > 0) { + // Give MCP connections time to attempt (they start during init) + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + const mcpStatuses = await q.mcpServerStatus(); + for (const server of mcpStatuses) { + if (server.status === "needs-auth") { + this.logger.log( + `[MCP OAuth] Server "${server.name}" needs auth, triggering OAuth flow...`, + ); + try { + // @ts-expect-error — mcp_authenticate is not in the public SDK types + const authResponse = await q.request({ + subtype: "mcp_authenticate", + serverName: server.name, + }); + const result = authResponse?.response ?? authResponse; + + if (result?.authUrl && result?.requiresUserAction) { + const { execSync: execSyncCmd } = await import("child_process"); + + // Open the auth URL in the user's browser. Mirrors the + // approach used by the CLI's internal openUrl function + // (minified as $Y): respects $BROWSER, uses platform- + // specific commands, and detects headless environments. + let opened = false; + try { + const browserEnv = process.env.BROWSER; + if (process.platform === "win32") { + if (browserEnv) { + execSyncCmd(`${browserEnv} "${result.authUrl}"`, { stdio: "ignore" }); + } else { + execSyncCmd(`rundll32 url,OpenURL ${result.authUrl}`, { stdio: "ignore" }); + } + opened = true; + } else { + const cmd = browserEnv || (process.platform === "darwin" ? "open" : "xdg-open"); + execSyncCmd(`${cmd} "${result.authUrl}"`, { stdio: "ignore" }); + opened = true; + } + } catch { + opened = false; + } + + if (opened) { + this.logger.log(`[MCP OAuth] Opening browser for "${server.name}"...`); + } else { + this.logger.error( + `[MCP OAuth] Cannot open browser (headless environment?). ` + + `Server "${server.name}" requires OAuth. ` + + `Authenticate manually or provide Authorization headers. ` + + `Auth URL: ${result.authUrl}`, + ); + continue; + } + + // Poll until connected (up to 60s) + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const newStatuses = await q.mcpServerStatus(); + const newStatus = newStatuses.find((s) => s.name === server.name); + if (newStatus?.status === "connected") { + this.logger.log(`[MCP OAuth] Server "${server.name}" connected!`); + break; + } + if (newStatus?.status !== "needs-auth" && newStatus?.status !== "pending") { + this.logger.error( + `[MCP OAuth] Server "${server.name}" unexpected status: ${newStatus?.status}`, + ); + break; + } + } + } else if (result?.requiresUserAction === false) { + this.logger.log( + `[MCP OAuth] Server "${server.name}" authenticated automatically (cached tokens)`, + ); + } + } catch (authError) { + this.logger.error(`[MCP OAuth] Auth failed for "${server.name}": ${authError}`); + } + } + } + } catch (statusError) { + this.logger.error(`[MCP OAuth] mcpServerStatus() failed: ${statusError}`); + } + } + if ( shouldHideClaudeAuth() && initializationResult.account.subscriptionType && From 180cb48c0411aa624f697603ba640589e42b2ae3 Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:52:53 -0400 Subject: [PATCH 2/2] chore: add bin/test script to run CI checks locally Parses .github/workflows/ci.yml to mirror CI steps with local adaptations: skips npm ci when node_modules is in sync and runs format:check only on git-tracked files to avoid x.* scratch files. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/test | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100755 bin/test diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..166495f --- /dev/null +++ b/bin/test @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Runs the same checks as CI by parsing .github/workflows/ci.yml directly. +# If CI steps change, this script automatically picks them up. +# +# Local adaptations: +# - `npm ci` checks if node_modules is in sync with package-lock.json +# and runs a clean install if not (CI always does npm ci). +# - `npm run format:check` checks only git-tracked files because CI +# runs on a clean checkout but locally we have untracked x.* scratch +# files that fail prettier. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +ci_yaml=".github/workflows/ci.yml" + +if ! command -v yq &>/dev/null; then + echo "error: yq is required (brew install yq)" >&2 + exit 1 +fi + +# Extract run steps +mapfile -t names < <(yq '.jobs.build.steps[] | select(.run) | .name' "$ci_yaml") +mapfile -t commands < <(yq '.jobs.build.steps[] | select(.run) | .run' "$ci_yaml") + +for i in "${!commands[@]}"; do + cmd="${commands[$i]}" + name="${names[$i]}" + + echo "=== ${name} ===" + + if [[ "$cmd" == "npm ci" ]]; then + # Check if node_modules matches package-lock.json. If not, run + # npm ci to match what CI does. This catches stale-dependency bugs + # like sdk-tools.d.ts resolving locally but not in CI. + if npm ls --all >/dev/null 2>&1; then + echo "(node_modules in sync — skipping npm ci)" + else + echo "(node_modules out of sync — running npm ci)" + npm ci + fi + elif [[ "$cmd" == "npm run format:check" ]]; then + # Local override: format:check on git-tracked files only + git ls-files -z '*.ts' '*.tsx' '*.js' '*.jsx' '*.json' '*.md' '*.yml' '*.yaml' '*.css' '*.html' \ + | xargs -0 npx prettier --check + else + eval "$cmd" + fi + + echo "" +done + +echo "=== All CI checks passed ==="