Skip to content

Commit 3d0eb27

Browse files
committed
feat(McpHub): update OAuth Flow to be opt in and handle concurrent flows across instances
1 parent 653b71e commit 3d0eb27

5 files changed

Lines changed: 403 additions & 59 deletions

File tree

src/services/mcp/McpHub.ts

Lines changed: 177 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export class McpHub {
168168
private initializationPromise: Promise<void>
169169
private secretStorage?: SecretStorageService
170170
private reauthPromises: Map<string, Promise<void>> = new Map()
171+
private _oauthWatchers: Map<string, { unsubscribe: () => void; abortHandle: NodeJS.Timeout }> = new Map()
171172

172173
constructor(provider: ClineProvider) {
173174
this.providerRef = new WeakRef(provider)
@@ -825,6 +826,13 @@ export class McpHub {
825826
const connection = this.findConnection(name, source)
826827
if (connection && connection.type === "connected") {
827828
if (error instanceof UnauthorizedError && authProvider) {
829+
// If we're already in the OAuth / polling flow, ignore transport
830+
// retries that surface another 401 — the poll or _completeOAuthFlow
831+
// will reconnect from scratch once tokens arrive.
832+
if (connection.server.status === "connecting") {
833+
return
834+
}
835+
828836
// Mid-session re-auth triggered by a tool call (401)
829837
connection.server.status = "connecting"
830838

@@ -861,6 +869,12 @@ export class McpHub {
861869
transport.onclose = async () => {
862870
const connection = this.findConnection(name, source)
863871
if (connection) {
872+
// If OAuth is in progress, don't overwrite "connecting" with "disconnected".
873+
// The transport will close/retry while we await the browser flow or poll;
874+
// the reconnect path (deleteConnection + connectToServer) handles cleanup.
875+
if (connection.server.status === "connecting") {
876+
return
877+
}
864878
connection.server.status = "disconnected"
865879
}
866880
await this.notifyWebviewOfServerChanges()
@@ -943,21 +957,69 @@ export class McpHub {
943957
try {
944958
await client.connect(transport)
945959
} catch (connectError) {
946-
if (connectError instanceof UnauthorizedError && streamableHttpAuthProvider) {
947-
// The server requires OAuth. The SDK has already called
948-
// authProvider.redirectToAuthorization() which started the local callback
949-
// server (lazily) and opened the user's browser.
950-
//
951-
// We fire-and-forget the rest of the flow so the extension (chat window,
952-
// other servers) is not blocked waiting for the user's browser session.
960+
if (connectError instanceof UnauthorizedError && streamableHttpAuthProvider && configInjected.url) {
961+
// The server requires OAuth. Mark this connection as "connecting" and
962+
// detach the toast + browser flow from the initialization path so that
963+
// waitUntilReady() resolves immediately and the MCP panel can load.
964+
const serverUrl = configInjected.url
953965
connection.server.status = "connecting"
954-
void this._completeOAuthFlow(
955-
streamableHttpAuthProvider,
956-
transport as StreamableHTTPClientTransport,
957-
connection,
958-
name,
959-
source,
960-
)
966+
967+
void (async () => {
968+
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000
969+
970+
// Check if another window already saved valid tokens
971+
const existing = await this.secretStorage!.getOAuthData(serverUrl)
972+
if (existing && Date.now() < existing.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
973+
await streamableHttpAuthProvider.close()
974+
await this.deleteConnection(name, source)
975+
await this.connectToServer(name, config, source)
976+
await this.notifyWebviewOfServerChanges()
977+
return
978+
}
979+
980+
// Show a confirmation toast so the user can decide whether to authenticate.
981+
// This resolves immediately when the user responds — but connectToServer
982+
// has already returned so the panel is not blocked.
983+
const choice = await vscode.window.showInformationMessage(
984+
`MCP server "${name}" requires authentication.`,
985+
"Authenticate",
986+
)
987+
988+
if (choice === "Authenticate") {
989+
// Check tokens again — another window may have authed while toast was showing
990+
const tokens = await this.secretStorage!.getOAuthData(serverUrl)
991+
if (tokens && Date.now() < tokens.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
992+
await streamableHttpAuthProvider.close()
993+
await this.deleteConnection(name, source)
994+
await this.connectToServer(name, config, source)
995+
await this.notifyWebviewOfServerChanges()
996+
return
997+
}
998+
void this._completeOAuthFlow(
999+
streamableHttpAuthProvider,
1000+
transport as StreamableHTTPClientTransport,
1001+
connection,
1002+
name,
1003+
source,
1004+
)
1005+
} else {
1006+
// Toast was dismissed or auto-timed-out.
1007+
// First do an immediate check — another window may have already
1008+
// completed auth while the toast was showing.
1009+
const tokens = await this.secretStorage!.getOAuthData(serverUrl)
1010+
if (tokens && Date.now() < tokens.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
1011+
await streamableHttpAuthProvider.close()
1012+
await this.deleteConnection(name, source)
1013+
await this.connectToServer(name, config, source)
1014+
await this.notifyWebviewOfServerChanges()
1015+
return
1016+
}
1017+
// Tokens not available yet — start watching for them
1018+
await streamableHttpAuthProvider.close()
1019+
this._watchForOAuthTokens(name, source, serverUrl, config)
1020+
}
1021+
})()
1022+
9611023
return
9621024
}
9631025
// Non-OAuth error — let the outer catch handle it.
@@ -1010,19 +1072,24 @@ export class McpHub {
10101072
name: string,
10111073
source: "global" | "project",
10121074
): Promise<void> {
1075+
const config = JSON.parse(connection.server.config)
10131076
try {
1077+
// Open the browser now that the user has confirmed the toast.
1078+
// redirectToAuthorization() was already called by the SDK (which stored
1079+
// the URL in _pendingAuthorizationUrl), but deliberately did not open it.
1080+
await authProvider.openBrowser()
1081+
10141082
const code = await authProvider.waitForAuthCode()
10151083
// Exchange auth code for tokens using the pre-fetched token_endpoint
10161084
// directly. The SDK's transport.finishAuth() re-runs discovery internally
10171085
// and hits the same broken URL for path-prefixed issuers (see
10181086
// utils/oauth.ts for upstream issue links).
10191087
await authProvider.exchangeCodeForTokens(code)
1020-
authProvider.close().catch(console.error)
1088+
await authProvider.close()
10211089

10221090
// Recover the validated server config stored on the connection so we
10231091
// can pass it directly to connectToServer without re-reading the file.
1024-
const parsedConfig = JSON.parse(connection.server.config)
1025-
const validatedConfig = this.validateServerConfig(parsedConfig, name)
1092+
const validatedConfig = this.validateServerConfig(config, name)
10261093

10271094
// Remove the broken connection (closes the old transport/client),
10281095
// then reconnect. The new McpOAuthClientProvider will find the token
@@ -1045,6 +1112,82 @@ export class McpHub {
10451112
}
10461113
}
10471114

1115+
private _watchForOAuthTokens(
1116+
name: string,
1117+
source: "global" | "project",
1118+
serverUrl: string,
1119+
config: z.infer<typeof ServerConfigSchema>,
1120+
): void {
1121+
if (!this.secretStorage) return
1122+
1123+
const watcherKey = `${name}:${source}`
1124+
1125+
// Cancel any existing watcher for this connection before starting a new one
1126+
const existing = this._oauthWatchers.get(watcherKey)
1127+
if (existing) {
1128+
existing.unsubscribe()
1129+
clearTimeout(existing.abortHandle)
1130+
this._oauthWatchers.delete(watcherKey)
1131+
}
1132+
1133+
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000
1134+
1135+
// Called when SecretStorage fires onDidChange for this server's key.
1136+
// Runs in all VS Code windows the instant tokens are saved — no polling delay.
1137+
const onTokensChanged = async () => {
1138+
try {
1139+
if (this.isDisposed) return
1140+
1141+
const conn = this.findConnection(name, source)
1142+
if (!conn || conn.server.status === "connected") {
1143+
cleanup()
1144+
return
1145+
}
1146+
1147+
const data = await this.secretStorage?.getOAuthData(serverUrl)
1148+
if (data && Date.now() < data.expires_at - TOKEN_EXPIRY_BUFFER_MS) {
1149+
cleanup()
1150+
await this.deleteConnection(name, source)
1151+
const validatedConfig = this.validateServerConfig(config, name)
1152+
await this.connectToServer(name, validatedConfig, source)
1153+
await this.notifyWebviewOfServerChanges()
1154+
}
1155+
// If tokens aren't valid yet (e.g. a delete event fired), keep listening.
1156+
} catch (err) {
1157+
console.error(`[McpHub] OAuth token watcher failed for "${name}":`, err)
1158+
}
1159+
}
1160+
1161+
const cleanup = () => {
1162+
const entry = this._oauthWatchers.get(watcherKey)
1163+
if (entry) {
1164+
entry.unsubscribe()
1165+
clearTimeout(entry.abortHandle)
1166+
this._oauthWatchers.delete(watcherKey)
1167+
}
1168+
}
1169+
1170+
const unsubscribe = this.secretStorage.onDidChange(serverUrl, () => {
1171+
void onTokensChanged()
1172+
})
1173+
1174+
// Give up after 6 minutes if no token ever arrives
1175+
const abortHandle = setTimeout(
1176+
() => {
1177+
cleanup()
1178+
const conn = this.findConnection(name, source)
1179+
if (conn && conn.server.status === "connecting") {
1180+
conn.server.status = "disconnected"
1181+
this.appendErrorMessage(conn, "OAuth authentication timed out waiting for another window")
1182+
void this.notifyWebviewOfServerChanges()
1183+
}
1184+
},
1185+
6 * 60 * 1000,
1186+
)
1187+
1188+
this._oauthWatchers.set(watcherKey, { unsubscribe, abortHandle })
1189+
}
1190+
10481191
private appendErrorMessage(connection: McpConnection, error: string, level: "error" | "warn" | "info" = "error") {
10491192
const MAX_ERROR_LENGTH = 1000
10501193
const truncatedError =
@@ -1222,6 +1365,15 @@ export class McpHub {
12221365
}
12231366

12241367
async deleteConnection(name: string, source?: "global" | "project"): Promise<void> {
1368+
// Cancel any active OAuth token watchers for this connection
1369+
const watcherKey = `${name}:${source}`
1370+
const watcher = this._oauthWatchers.get(watcherKey)
1371+
if (watcher) {
1372+
watcher.unsubscribe()
1373+
clearTimeout(watcher.abortHandle)
1374+
this._oauthWatchers.delete(watcherKey)
1375+
}
1376+
12251377
// Clean up file watchers for this server
12261378
this.removeFileWatchersForServer(name)
12271379

@@ -2181,6 +2333,14 @@ export class McpHub {
21812333
}
21822334

21832335
this.isProgrammaticUpdate = false
2336+
2337+
// Cancel all active OAuth token watchers
2338+
for (const { unsubscribe, abortHandle } of this._oauthWatchers.values()) {
2339+
unsubscribe()
2340+
clearTimeout(abortHandle)
2341+
}
2342+
this._oauthWatchers.clear()
2343+
21842344
this.removeAllFileWatchers()
21852345

21862346
for (const connection of this.connections) {

src/services/mcp/McpOAuthClientProvider.ts

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
3737
private _clientInfo?: OAuthClientInformationFull
3838
private _closed = false
3939
private _refreshPromise: Promise<OAuthTokens> | null = null
40+
/** Stored by redirectToAuthorization(); opened on-demand via openBrowser(). */
41+
private _pendingAuthorizationUrl: URL | null = null
42+
/** Deduplicates concurrent _ensureCallbackServer() calls. */
43+
private _ensureServerPromise: Promise<void> | null = null
4044

4145
private constructor(
4246
private readonly _serverUrl: string,
@@ -88,23 +92,15 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
8892
.map((b) => b.toString(16).padStart(2, "0"))
8993
.join("")
9094

91-
// Start the callback server now so the port is known and stable.
92-
// The SDK reads `redirectUrl` synchronously when building the authorization
93-
// URL, so the port must be available before any connect attempt.
94-
const { server, port, result } = await startCallbackServer(undefined, state)
95-
96-
const authCodePromise = result.then((r) => {
97-
if (r.error) throw new Error(`OAuth authorization failed: ${r.error}`)
98-
if (!r.code) throw new Error("No authorization code received in callback")
99-
return r.code
100-
})
95+
// We start the callback server lazily in `redirectToAuthorization()` or `waitForAuthCode()`.
96+
// We use a default port (0) initially; it will be updated when the server starts.
10197

10298
return new McpOAuthClientProvider(
10399
serverUrl,
104100
secretStorage,
105-
server,
106-
port,
107-
authCodePromise,
101+
null,
102+
0,
103+
null,
108104
tokenEndpointAuthMethod,
109105
grantTypes,
110106
scopes,
@@ -121,9 +117,20 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
121117
return `http://localhost:${this._port}/callback`
122118
}
123119

124-
private async _ensureCallbackServer(): Promise<void> {
125-
if (this._server && !this._closed) return
120+
private _ensureCallbackServer(): Promise<void> {
121+
// Guard against concurrent callers (e.g. redirectToAuthorization + registerClientIfNeeded
122+
// called in parallel) both passing the "server not yet started" check and each launching
123+
// their own startCallbackServer(), which would bind two ports and lose one handle.
124+
if (this._server && !this._closed) return Promise.resolve()
125+
if (!this._ensureServerPromise) {
126+
this._ensureServerPromise = this._doStartCallbackServer().finally(() => {
127+
this._ensureServerPromise = null
128+
})
129+
}
130+
return this._ensureServerPromise
131+
}
126132

133+
private async _doStartCallbackServer(): Promise<void> {
127134
this._closed = false
128135
const { server, port, result } = await startCallbackServer(this._port, this._state)
129136
this._server = server
@@ -188,6 +195,10 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
188195

189196
if (!this._authServerMeta?.registration_endpoint) return // DCR not supported
190197

198+
// For Dynamic Client Registration, we MUST have a stable redirect URI.
199+
// Ensure the callback server is started so we have a real port.
200+
await this._ensureCallbackServer()
201+
191202
const response = await fetch(this._authServerMeta.registration_endpoint as string, {
192203
method: "POST",
193204
headers: {
@@ -254,8 +265,11 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
254265
}
255266

256267
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
257-
// Ensure the callback server is running before opening the browser.
258-
// This handles mid-session re-auth where the initial server was closed.
268+
// Ensure the callback server is running so redirectUrl has a real port.
269+
// The server must be started here because the SDK calls this method as
270+
// part of its internal auth flow (before throwing UnauthorizedError back
271+
// to our caller). We do NOT open the browser here — that is deferred to
272+
// openBrowser(), which McpHub calls only after the user confirms the toast.
259273
await this._ensureCallbackServer()
260274

261275
// Workaround for SDK metadata discovery bug (see utils/oauth.ts for issue links).
@@ -290,13 +304,25 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
290304
}
291305
}
292306

293-
void vscode.window.showInformationMessage("MCP server requires authentication. Opening browser for OAuth…")
307+
// Store the (possibly corrected) URL; it will be opened by openBrowser()
308+
// once the user confirms the "Authenticate" toast in McpHub.
309+
this._pendingAuthorizationUrl = correctedUrl
310+
}
311+
312+
/**
313+
* Opens the pending OAuth authorization URL in the system browser.
314+
* Must be called after `redirectToAuthorization()` has been invoked by the SDK.
315+
* McpHub calls this only after the user confirms the authentication toast.
316+
*/
317+
async openBrowser(): Promise<void> {
318+
const url = this._pendingAuthorizationUrl
319+
if (!url) {
320+
throw new Error("No pending authorization URL — redirectToAuthorization() was not called")
321+
}
294322
try {
295-
await vscode.env.openExternal(vscode.Uri.parse(correctedUrl.toString()))
323+
await vscode.env.openExternal(vscode.Uri.parse(url.toString()))
296324
} catch {
297-
void vscode.window.showInformationMessage(
298-
`Please open this URL in your browser to authenticate: ${correctedUrl}`,
299-
)
325+
void vscode.window.showInformationMessage(`Please open this URL in your browser to authenticate: ${url}`)
300326
}
301327
}
302328

@@ -429,6 +455,11 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
429455

430456
/** Close the local callback server. Always call this when done. */
431457
async close(): Promise<void> {
458+
// If a server startup is in flight, wait for it to finish so we don't
459+
// close before _server is set (which would leave a dangling server).
460+
if (this._ensureServerPromise) {
461+
await this._ensureServerPromise.catch(() => {})
462+
}
432463
if (!this._closed && this._server) {
433464
this._closed = true
434465
await stopCallbackServer(this._server).catch(() => {})

0 commit comments

Comments
 (0)