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
84 changes: 70 additions & 14 deletions src/services/mcp/McpOAuthClientProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@
private _effectiveResourceIndicator: string | null
/** Deduplicates concurrent _ensureCallbackServer() calls. */
private _ensureServerPromise: Promise<void> | null = null
/** Callback promise most recently returned to a waitForAuthCode() caller. */
private _awaitedAuthCodePromise: Promise<string> | null = null
/**
* Rejected callback promise of a listener that failed while no caller was waiting.
* The next waitForAuthCode() call reports it once instead of starting another listener.
*/
private _unreportedAuthCodeFailure: Promise<string> | null = null
/** Stops a detached listener. A replacement listener waits for it because it may reuse the port. */
private _listenerCleanup: Promise<void> | null = null

private constructor(
private readonly _serverUrl: string,
Expand Down Expand Up @@ -243,6 +252,11 @@

private async _doStartCallbackServer(): Promise<void> {
this._closed = false
// A new listener belongs to a new authorization attempt, so an earlier failure no longer applies.
this._unreportedAuthCodeFailure = null
if (this._listenerCleanup) {

Check warning on line 257 in src/services/mcp/McpOAuthClientProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/mcp/McpOAuthClientProvider.ts:257: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await this._listenerCleanup
}
const { port: configuredPort, host, path } = getCallbackAddress(this._configuredRedirectUri)
const { server, port, result, cancel } = await startCallbackServer(
configuredPort ?? this._port,
Expand All @@ -261,18 +275,31 @@
this._authCodePromise = authCodePromise

// Static clients can start this promise before waitForAuthCode() observes it.
// Observe rejection now to prevent an unhandled rejection, but keep the original
// promise so waitForAuthCode() still receives the callback error. Stop only this
// promise's listener before clearing it so a later flow can bind a fresh listener.
void authCodePromise.catch(async () => {
if (this._authCodePromise === authCodePromise && this._server === server) {
await stopCallbackServer(server, cancel).catch(() => {})
if (this._authCodePromise === authCodePromise && this._server === server) {
this._server = null
this._cancelCallbackServer = null
}
// Observe rejection now to prevent an unhandled rejection. A caller that is already
// waiting receives the error from this promise; otherwise keep the rejected promise so
// the next waitForAuthCode() call reports it instead of waiting on a new listener.
void authCodePromise.catch(() => {
// close() detaches this promise before it cancels the listener and stops it itself.
if (this._authCodePromise !== authCodePromise) return
if (this._awaitedAuthCodePromise !== authCodePromise) {
this._unreportedAuthCodeFailure = authCodePromise
}
// Detach only the failed listener so a later authorization attempt starts a fresh one.
// Keep the failed promise current for callers that are still resuming from startup.
this._server = null
this._cancelCallbackServer = null
this._stopDetachedListener(server, cancel)
})
}

/** Stops a listener that is no longer attached. A replacement listener waits because it may reuse the port. */
private _stopDetachedListener(server: http.Server, cancel: () => void): void {
const cleanup = stopCallbackServerIgnoringErrors(server, cancel).finally(() => {
if (this._listenerCleanup === cleanup) {

Check warning on line 298 in src/services/mcp/McpOAuthClientProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/mcp/McpOAuthClientProvider.ts:298: 3 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
this._listenerCleanup = null
}
})
this._listenerCleanup = cleanup
}

state(): string {
Expand Down Expand Up @@ -521,12 +548,24 @@
* Resolves with the authorization code once the user completes the OAuth
* browser flow and the local callback server receives the redirect.
* Rejects on error or 5-minute timeout.
*
* If the listener failed before any caller waited, the first call rejects with
* that failure without starting another listener. A later call starts a fresh one.
*/
async waitForAuthCode(): Promise<string> {
if (!this._authCodePromise || !this._server) {
if (!this._unreportedAuthCodeFailure && (!this._authCodePromise || !this._server)) {

Check warning on line 556 in src/services/mcp/McpOAuthClientProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/mcp/McpOAuthClientProvider.ts:556: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await this._ensureCallbackServer()
}
return this._authCodePromise!
const unreportedFailure = this._unreportedAuthCodeFailure
if (unreportedFailure) {
this._unreportedAuthCodeFailure = null
return unreportedFailure
}
// A caller that waited for listener startup receives that listener's result, even when
// another caller already took the failure while this caller was resuming.
const authCodePromise = this._authCodePromise!
this._awaitedAuthCodePromise = authCodePromise
return authCodePromise
}

/**
Expand Down Expand Up @@ -655,13 +694,30 @@
if (this._ensureServerPromise) {
await this._ensureServerPromise.catch(() => {})
}
if (!this._closed && this._server) {
// A closed flow must not report its failure to a later flow.
this._unreportedAuthCodeFailure = null
const server = this._server
if (!this._closed && server) {
this._closed = true
await stopCallbackServer(this._server, this._cancelCallbackServer ?? (() => {})).catch(() => {})
const cancel = this._cancelCallbackServer ?? (() => {})
// Detach before cancelling so the rejection handler does not record the cancellation.
this._server = null
this._cancelCallbackServer = null
this._authCodePromise = null
this._stopDetachedListener(server, cancel)
}
if (this._listenerCleanup) {

Check warning on line 709 in src/services/mcp/McpOAuthClientProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/mcp/McpOAuthClientProvider.ts:709: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
await this._listenerCleanup
}
}
}

/** Stops a callback listener. A cleanup error must not replace the callback error. */
async function stopCallbackServerIgnoringErrors(server: http.Server, cancel: () => void): Promise<void> {
try {
await stopCallbackServer(server, cancel)
} catch {
// The callback result, not the cleanup result, is the error that callers report.
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/services/mcp/__tests__/McpHub.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3211,6 +3211,29 @@ describe("McpHub", () => {
).resolves.toBeUndefined()
})

it("should record the callback error and disconnect when waitForAuthCode rejects", async () => {
vsc.window.showInformationMessage.mockResolvedValueOnce(t("mcp:oauth.flow.authenticateButton"))
// The provider reports a callback listener failure, such as a timeout before the user authenticated.
mockAuthProvider.waitForAuthCode.mockRejectedValueOnce(new Error("Callback timeout"))

await mcpHub["_initiateOAuthFlow"](
serverName,
source,
config,
mockAuthProvider,
mockTransport,
mockConnection,
)

expect(mockAuthProvider.openBrowser).toHaveBeenCalledTimes(1)
expect(mockAuthProvider.exchangeCodeForTokens).not.toHaveBeenCalled()
expect(mockAuthProvider.close).toHaveBeenCalled()
expect(mockConnection.server.status).toBe("disconnected")
expect(mcpHub["appendErrorMessage"]).toHaveBeenCalledTimes(1)
expect(mcpHub["appendErrorMessage"]).toHaveBeenCalledWith(mockConnection, "Callback timeout")
expect(mcpHub["connectToServer"]).not.toHaveBeenCalled()
})

it("should disconnect and flag error when Cancel is pressed after Authenticate is clicked", async () => {
// Simulate: user clicks Authenticate in the toast, then cancels via the progress bar
// while waitForAuthCode is still pending.
Expand Down
Loading
Loading