Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions bin/test
Original file line number Diff line number Diff line change
@@ -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 ==="
98 changes: 98 additions & 0 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,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 &&
Expand Down