Skip to content

Commit 4fd633e

Browse files
timvisher-ddclaude
andcommitted
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) <noreply@anthropic.com>
1 parent d07799d commit 4fd633e

1 file changed

Lines changed: 98 additions & 0 deletions

File tree

src/acp-agent.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,104 @@ export class ClaudeAcpAgent implements Agent {
14501450
throw error;
14511451
}
14521452

1453+
// MCP OAuth: detect servers that need authentication and trigger the
1454+
// SDK's built-in OAuth flow. The Claude Code CLI subprocess handles
1455+
// the full PKCE flow (RFC 9728 discovery, dynamic client registration,
1456+
// localhost callback server, token exchange, keychain storage).
1457+
//
1458+
// The `mcp_authenticate` control message is an undocumented internal
1459+
// API of the Claude Code CLI. It triggers OAuth discovery for the
1460+
// named server and returns an `authUrl` for user consent. The CLI
1461+
// starts a localhost callback server to receive the authorization code.
1462+
if (!creationOpts?.resume && Object.keys(mcpServers).length > 0) {
1463+
// Give MCP connections time to attempt (they start during init)
1464+
await new Promise((resolve) => setTimeout(resolve, 2000));
1465+
1466+
try {
1467+
const mcpStatuses = await q.mcpServerStatus();
1468+
for (const server of mcpStatuses) {
1469+
if (server.status === "needs-auth") {
1470+
this.logger.log(
1471+
`[MCP OAuth] Server "${server.name}" needs auth, triggering OAuth flow...`,
1472+
);
1473+
try {
1474+
// @ts-expect-error — mcp_authenticate is not in the public SDK types
1475+
const authResponse = await q.request({
1476+
subtype: "mcp_authenticate",
1477+
serverName: server.name,
1478+
});
1479+
const result = authResponse?.response ?? authResponse;
1480+
1481+
if (result?.authUrl && result?.requiresUserAction) {
1482+
const { execSync: execSyncCmd } = await import("child_process");
1483+
1484+
// Open the auth URL in the user's browser. Mirrors the
1485+
// approach used by the CLI's internal openUrl function
1486+
// (minified as $Y): respects $BROWSER, uses platform-
1487+
// specific commands, and detects headless environments.
1488+
let opened = false;
1489+
try {
1490+
const browserEnv = process.env.BROWSER;
1491+
if (process.platform === "win32") {
1492+
if (browserEnv) {
1493+
execSyncCmd(`${browserEnv} "${result.authUrl}"`, { stdio: "ignore" });
1494+
} else {
1495+
execSyncCmd(`rundll32 url,OpenURL ${result.authUrl}`, { stdio: "ignore" });
1496+
}
1497+
opened = true;
1498+
} else {
1499+
const cmd = browserEnv || (process.platform === "darwin" ? "open" : "xdg-open");
1500+
execSyncCmd(`${cmd} "${result.authUrl}"`, { stdio: "ignore" });
1501+
opened = true;
1502+
}
1503+
} catch {
1504+
opened = false;
1505+
}
1506+
1507+
if (opened) {
1508+
this.logger.log(`[MCP OAuth] Opening browser for "${server.name}"...`);
1509+
} else {
1510+
this.logger.error(
1511+
`[MCP OAuth] Cannot open browser (headless environment?). ` +
1512+
`Server "${server.name}" requires OAuth. ` +
1513+
`Authenticate manually or provide Authorization headers. ` +
1514+
`Auth URL: ${result.authUrl}`,
1515+
);
1516+
continue;
1517+
}
1518+
1519+
// Poll until connected (up to 60s)
1520+
const deadline = Date.now() + 60000;
1521+
while (Date.now() < deadline) {
1522+
await new Promise((resolve) => setTimeout(resolve, 2000));
1523+
const newStatuses = await q.mcpServerStatus();
1524+
const newStatus = newStatuses.find((s) => s.name === server.name);
1525+
if (newStatus?.status === "connected") {
1526+
this.logger.log(`[MCP OAuth] Server "${server.name}" connected!`);
1527+
break;
1528+
}
1529+
if (newStatus?.status !== "needs-auth" && newStatus?.status !== "pending") {
1530+
this.logger.error(
1531+
`[MCP OAuth] Server "${server.name}" unexpected status: ${newStatus?.status}`,
1532+
);
1533+
break;
1534+
}
1535+
}
1536+
} else if (result?.requiresUserAction === false) {
1537+
this.logger.log(
1538+
`[MCP OAuth] Server "${server.name}" authenticated automatically (cached tokens)`,
1539+
);
1540+
}
1541+
} catch (authError) {
1542+
this.logger.error(`[MCP OAuth] Auth failed for "${server.name}": ${authError}`);
1543+
}
1544+
}
1545+
}
1546+
} catch (statusError) {
1547+
this.logger.error(`[MCP OAuth] mcpServerStatus() failed: ${statusError}`);
1548+
}
1549+
}
1550+
14531551
if (
14541552
shouldHideClaudeAuth() &&
14551553
initializationResult.account.subscriptionType &&

0 commit comments

Comments
 (0)