Skip to content

Commit d8e9b90

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 c13edf3 commit d8e9b90

File tree

1 file changed

+98
-0
lines changed

1 file changed

+98
-0
lines changed

src/acp-agent.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,104 @@ export class ClaudeAcpAgent implements Agent {
13121312
throw error;
13131313
}
13141314

1315+
// MCP OAuth: detect servers that need authentication and trigger the
1316+
// SDK's built-in OAuth flow. The Claude Code CLI subprocess handles
1317+
// the full PKCE flow (RFC 9728 discovery, dynamic client registration,
1318+
// localhost callback server, token exchange, keychain storage).
1319+
//
1320+
// The `mcp_authenticate` control message is an undocumented internal
1321+
// API of the Claude Code CLI. It triggers OAuth discovery for the
1322+
// named server and returns an `authUrl` for user consent. The CLI
1323+
// starts a localhost callback server to receive the authorization code.
1324+
if (!creationOpts?.resume) {
1325+
// Give MCP connections time to attempt (they start during init)
1326+
await new Promise((resolve) => setTimeout(resolve, 2000));
1327+
1328+
try {
1329+
const mcpStatuses = await q.mcpServerStatus();
1330+
for (const server of mcpStatuses) {
1331+
if (server.status === "needs-auth") {
1332+
this.logger.log(
1333+
`[MCP OAuth] Server "${server.name}" needs auth, triggering OAuth flow...`,
1334+
);
1335+
try {
1336+
// @ts-expect-error — mcp_authenticate is not in the public SDK types
1337+
const authResponse = await q.request({
1338+
subtype: "mcp_authenticate",
1339+
serverName: server.name,
1340+
});
1341+
const result = authResponse?.response ?? authResponse;
1342+
1343+
if (result?.authUrl && result?.requiresUserAction) {
1344+
const { execSync: execSyncCmd } = await import("child_process");
1345+
1346+
// Open the auth URL in the user's browser. Mirrors the
1347+
// approach used by the CLI's internal openUrl function
1348+
// (minified as $Y): respects $BROWSER, uses platform-
1349+
// specific commands, and detects headless environments.
1350+
let opened = false;
1351+
try {
1352+
const browserEnv = process.env.BROWSER;
1353+
if (process.platform === "win32") {
1354+
if (browserEnv) {
1355+
execSyncCmd(`${browserEnv} "${result.authUrl}"`, { stdio: "ignore" });
1356+
} else {
1357+
execSyncCmd(`rundll32 url,OpenURL ${result.authUrl}`, { stdio: "ignore" });
1358+
}
1359+
opened = true;
1360+
} else {
1361+
const cmd = browserEnv || (process.platform === "darwin" ? "open" : "xdg-open");
1362+
execSyncCmd(`${cmd} "${result.authUrl}"`, { stdio: "ignore" });
1363+
opened = true;
1364+
}
1365+
} catch {
1366+
opened = false;
1367+
}
1368+
1369+
if (opened) {
1370+
this.logger.log(`[MCP OAuth] Opening browser for "${server.name}"...`);
1371+
} else {
1372+
this.logger.error(
1373+
`[MCP OAuth] Cannot open browser (headless environment?). ` +
1374+
`Server "${server.name}" requires OAuth. ` +
1375+
`Authenticate manually or provide Authorization headers. ` +
1376+
`Auth URL: ${result.authUrl}`,
1377+
);
1378+
continue;
1379+
}
1380+
1381+
// Poll until connected (up to 60s)
1382+
const deadline = Date.now() + 60000;
1383+
while (Date.now() < deadline) {
1384+
await new Promise((resolve) => setTimeout(resolve, 2000));
1385+
const newStatuses = await q.mcpServerStatus();
1386+
const newStatus = newStatuses.find((s) => s.name === server.name);
1387+
if (newStatus?.status === "connected") {
1388+
this.logger.log(`[MCP OAuth] Server "${server.name}" connected!`);
1389+
break;
1390+
}
1391+
if (newStatus?.status !== "needs-auth" && newStatus?.status !== "pending") {
1392+
this.logger.error(
1393+
`[MCP OAuth] Server "${server.name}" unexpected status: ${newStatus?.status}`,
1394+
);
1395+
break;
1396+
}
1397+
}
1398+
} else if (result?.requiresUserAction === false) {
1399+
this.logger.log(
1400+
`[MCP OAuth] Server "${server.name}" authenticated automatically (cached tokens)`,
1401+
);
1402+
}
1403+
} catch (authError) {
1404+
this.logger.error(`[MCP OAuth] Auth failed for "${server.name}": ${authError}`);
1405+
}
1406+
}
1407+
}
1408+
} catch (statusError) {
1409+
this.logger.error(`[MCP OAuth] mcpServerStatus() failed: ${statusError}`);
1410+
}
1411+
}
1412+
13151413
if (shouldHideClaudeAuth() && initializationResult.account.subscriptionType) {
13161414
throw RequestError.authRequired(
13171415
undefined,

0 commit comments

Comments
 (0)