Skip to content
Merged
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
55 changes: 52 additions & 3 deletions shared/glean/mcp/src/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import type {
} from "@modelcontextprotocol/client";
import { execFile, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { platform } from "node:os";
import { getCallbackUrl, setExpectedState } from "./auth-callback-server.js";
import { clearCredentials, loadCredentials, saveCredentials } from "./token-store.js";
import {
clearCredentials,
loadCredentials,
saveCredentials,
} from "./token-store.js";

export type InvalidationScope =
| "all"
Expand All @@ -17,6 +22,10 @@ export type InvalidationScope =
| "verifier"
| "discovery";

// Grace window for a sibling's in-flight refresh to land on disk.
const ROTATION_GRACE_MS = 2000;
const ROTATION_POLL_MS = 500;

/**
* Open `url` in the user's default browser. Used for the self-open sign-in
* path when the client does not support URL-mode elicitation (where the client
Expand Down Expand Up @@ -53,7 +62,6 @@ export class GleanOAuthClientProvider implements OAuthClientProvider {
// explicitly invalidating. Used to detect when a previous auth URL didn't
// complete — likely because the server rejected the (stale) client_id.
private _authUrlPending = false;

authorizationUrl: string | undefined;

/**
Expand All @@ -72,6 +80,33 @@ export class GleanOAuthClientProvider implements OAuthClientProvider {
}
}

// Re-read the shared store on every token access so a sibling's rotated
// grant is used instead of a stale in-memory copy.
private syncTokensFromDisk(): void {
const stored = loadCredentials();
if (!stored) return;
if (stored.tokens) {
this._tokens = stored.tokens;
}
if (stored.clientInfo) {
this._clientInfo = stored.clientInfo;
}
}

// Wait for a sibling's refresh to land on disk. Returns true once a
// different access token is available for adoption/retry.
async waitForSiblingRefresh(
previousAccessToken: string | undefined,
): Promise<boolean> {
const deadline = Date.now() + ROTATION_GRACE_MS;
for (;;) {
const current = this.tokens()?.access_token;
if (current && current !== previousAccessToken) return true;
if (Date.now() >= deadline) return false;
await sleep(ROTATION_POLL_MS);
}
}

get redirectUrl(): string {
return getCallbackUrl();
}
Expand All @@ -93,6 +128,7 @@ export class GleanOAuthClientProvider implements OAuthClientProvider {
}

tokens(): StoredOAuthTokens | undefined {
this.syncTokensFromDisk();
return this._tokens;
}

Expand All @@ -118,10 +154,23 @@ export class GleanOAuthClientProvider implements OAuthClientProvider {
this._clientInfo = undefined;
saveCredentials(this._tokens, undefined);
break;
case "tokens":
case "tokens": {
Comment thread
pragati-agrawal-glean marked this conversation as resolved.
// SDK auth() invalidates tokens after invalid_grant, which can mean a
// sibling already rotated our refresh token. Client errors invalidate
// "client" before "tokens" instead: without a retained client, clear
// immediately. A newer token must not cancel a client or full reset.
const previousAccessToken = this._tokens?.access_token;
if (
this._clientInfo &&
this._tokens?.refresh_token &&
(await this.waitForSiblingRefresh(previousAccessToken))
) {
return;
}
this._tokens = undefined;
saveCredentials(undefined, this._clientInfo);
break;
}
case "verifier":
this._codeVerifier = "";
break;
Expand Down
53 changes: 50 additions & 3 deletions shared/glean/mcp/src/remote-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
Client,
StreamableHTTPClientTransport,
UnauthorizedError,
OAuthError,
OAuthErrorCode,
type ElicitRequest,
type ElicitResult,
} from "@modelcontextprotocol/client";
Expand Down Expand Up @@ -177,6 +179,7 @@ export async function createRemoteClient(
serverUrl: string,
opts: RemoteClientOptions,
chatSessionId?: string,
authRetry = false,
): Promise<Client> {
const authProvider = opts.authProvider;

Expand Down Expand Up @@ -245,21 +248,65 @@ export async function createRemoteClient(
});
}

// Snapshot to detect a sibling's refresh between connect and failure.
const accessTokenAtConnect = authProvider?.tokens()?.access_token;

const transport = buildTransport(serverUrl, opts, chatSessionId);

try {
await withConnectLock(() => client.connect(transport));
} catch (error) {
if (error instanceof UnauthorizedError && authProvider?.authorizationUrl) {
pendingTransport = transport;
throw new AuthRequiredError(authProvider.authorizationUrl);
if (!authProvider) {
throw error;
}

if (error instanceof UnauthorizedError) {
const refreshedAccessToken = authProvider.tokens()?.access_token;
Comment thread
pragati-agrawal-glean marked this conversation as resolved.
if (
!authRetry &&
refreshedAccessToken &&
refreshedAccessToken !== accessTokenAtConnect
) {
console.error(
"[auth] Auth failed but a newer token is on disk " +
"(sibling refresh) — retrying once",
);
return createRemoteClient(serverUrl, opts, chatSessionId, true);
}
if (authProvider.authorizationUrl) {
Comment thread
pragati-agrawal-glean marked this conversation as resolved.
pendingTransport = transport;
throw new AuthRequiredError(authProvider.authorizationUrl);
}
}
// Concurrent-refresh losers are reported with structured OAuth errors
// (typically invalid_request); retry once if a sibling's grant lands in the
// grace window.
if (
!authRetry &&
isRefreshOAuthError(error) &&
(await authProvider.waitForSiblingRefresh(accessTokenAtConnect))
) {
console.error(
"[auth] Refresh failed but a sibling refreshed — retrying with its token",
);
return createRemoteClient(serverUrl, opts, chatSessionId, true);
}
throw error;
}

return client;
}

// Restrict recovery to OAuth errors that can indicate a refresh race. The SDK
// preserves the response's machine-readable error code.
function isRefreshOAuthError(error: unknown): boolean {
return (
error instanceof OAuthError &&
(error.code === OAuthErrorCode.InvalidRequest ||
error.code === OAuthErrorCode.InvalidGrant)
);
}

export async function callRemoteTool(
client: Client,
name: string,
Expand Down
7 changes: 2 additions & 5 deletions shared/glean/mcp/src/token-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
import fs from "node:fs";
import path from "node:path";
import { serverDataDir } from "./data-dir.js";
import { writeFileAtomicSync } from "./atomic-write.js";

const CREDENTIALS_FILENAME = "mcp-credentials.json";
const DIR_MODE = 0o700;
Expand Down Expand Up @@ -38,11 +39,7 @@ export function saveCredentials(
fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
fs.chmodSync(dir, DIR_MODE);
const data: StoredCredentials = { tokens, clientInfo };
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), {
encoding: "utf-8",
mode: FILE_MODE,
});
fs.chmodSync(filePath, FILE_MODE);
writeFileAtomicSync(filePath, JSON.stringify(data, null, 2), FILE_MODE);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[auth] Failed to persist credentials: ${msg}`);
Expand Down
Loading