Skip to content

Commit 472220a

Browse files
authored
fix: multiple fixes on auth flow edge cases (aws#2155)
1 parent 7296f93 commit 472220a

File tree

4 files changed

+73
-40
lines changed

4 files changed

+73
-40
lines changed

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ enum TransportType {
3535
}
3636

3737
export class McpEventHandler {
38+
private static readonly FILE_WATCH_DEBOUNCE_MS = 2000
3839
#features: Features
3940
#eventListenerRegistered: boolean
4041
#currentEditingServerName: string | undefined
@@ -48,6 +49,12 @@ export class McpEventHandler {
4849
#lastProgrammaticState: boolean = false
4950
#serverNameBeforeUpdate: string | undefined
5051

52+
#releaseProgrammaticAfterDebounce(padMs = 500) {
53+
setTimeout(() => {
54+
this.#isProgrammaticChange = false
55+
}, McpEventHandler.FILE_WATCH_DEBOUNCE_MS + padMs)
56+
}
57+
5158
constructor(features: Features, telemetryService: TelemetryService) {
5259
this.#features = features
5360
this.#eventListenerRegistered = false
@@ -797,7 +804,7 @@ export class McpEventHandler {
797804
command: selectedTransport === TransportType.STDIO ? params.optionsValues.command : undefined,
798805
url: selectedTransport === TransportType.HTTP ? params.optionsValues.url : undefined,
799806
enabled: true,
800-
numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length,
807+
numTools: McpManager.instance.getAllToolsWithPermissions(sanitizedServerName).length,
801808
scope: params.optionsValues['scope'] === 'global' ? 'global' : 'workspace',
802809
transportType: selectedTransport,
803810
languageServerVersion: this.#features.runtime.serverInfo.version,
@@ -812,6 +819,7 @@ export class McpEventHandler {
812819

813820
// Stay on add/edit page and show error to user
814821
// Keep isProgrammaticChange true during error handling to prevent file watcher triggers
822+
this.#releaseProgrammaticAfterDebounce()
815823
if (isEditMode) {
816824
params.id = 'edit-mcp'
817825
params.title = sanitizedServerName
@@ -826,7 +834,7 @@ export class McpEventHandler {
826834
this.#newlyAddedServers.delete(serverName)
827835
}
828836

829-
this.#isProgrammaticChange = false
837+
this.#releaseProgrammaticAfterDebounce()
830838

831839
// Go to tools permissions page
832840
return this.#handleOpenMcpServer({ id: 'open-mcp-server', title: sanitizedServerName })
@@ -927,9 +935,10 @@ export class McpEventHandler {
927935
perm.__configPath__ = agentPath
928936
await mcpManager.updateServerPermission(serverName, perm)
929937
this.#emitMCPConfigEvent()
938+
this.#releaseProgrammaticAfterDebounce()
930939
} catch (error) {
931940
this.#features.logging.error(`Failed to enable MCP server: ${error}`)
932-
this.#isProgrammaticChange = false
941+
this.#releaseProgrammaticAfterDebounce()
933942
}
934943
return { id: params.id }
935944
}
@@ -953,9 +962,10 @@ export class McpEventHandler {
953962
perm.__configPath__ = agentPath
954963
await mcpManager.updateServerPermission(serverName, perm)
955964
this.#emitMCPConfigEvent()
965+
this.#releaseProgrammaticAfterDebounce()
956966
} catch (error) {
957967
this.#features.logging.error(`Failed to disable MCP server: ${error}`)
958-
this.#isProgrammaticChange = false
968+
this.#releaseProgrammaticAfterDebounce()
959969
}
960970

961971
return { id: params.id }
@@ -975,11 +985,11 @@ export class McpEventHandler {
975985

976986
try {
977987
await McpManager.instance.removeServer(serverName)
978-
988+
this.#releaseProgrammaticAfterDebounce()
979989
return { id: params.id }
980990
} catch (error) {
981991
this.#features.logging.error(`Failed to delete MCP server: ${error}`)
982-
this.#isProgrammaticChange = false
992+
this.#releaseProgrammaticAfterDebounce()
983993
return { id: params.id }
984994
}
985995
}
@@ -1262,10 +1272,11 @@ export class McpEventHandler {
12621272
this.#pendingPermissionConfig = undefined
12631273

12641274
this.#features.logging.info(`Applied permission changes for server: ${serverName}`)
1275+
this.#releaseProgrammaticAfterDebounce()
12651276
return { id: params.id }
12661277
} catch (error) {
12671278
this.#features.logging.error(`Failed to save MCP permissions: ${error}`)
1268-
this.#isProgrammaticChange = false
1279+
this.#releaseProgrammaticAfterDebounce()
12691280
return { id: params.id }
12701281
}
12711282
}
@@ -1430,7 +1441,8 @@ export class McpEventHandler {
14301441
*/
14311442
#getServerStatusError(serverName: string): { title: string; icon: string; status: Status } | undefined {
14321443
const serverStates = McpManager.instance.getAllServerStates()
1433-
const serverState = serverStates.get(serverName)
1444+
const key = serverName ? sanitizeName(serverName) : serverName
1445+
const serverState = key ? serverStates.get(key) : undefined
14341446

14351447
if (!serverState) {
14361448
return undefined
@@ -1494,11 +1506,10 @@ export class McpEventHandler {
14941506
if (!this.#lastProgrammaticState) {
14951507
await this.#handleRefreshMCPList({ id: 'refresh-mcp-list' })
14961508
} else {
1497-
this.#isProgrammaticChange = false
14981509
this.#features.logging.debug('Skipping refresh due to programmatic change')
14991510
}
15001511
this.#debounceTimer = null
1501-
}, 2000)
1512+
}, McpEventHandler.FILE_WATCH_DEBOUNCE_MS)
15021513
})
15031514
}
15041515

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export class McpManager {
170170
/**
171171
* Load configurations and initialize each enabled server.
172172
*/
173-
private async discoverAllServers(authIntent: AuthIntent = AuthIntent.Silent): Promise<void> {
173+
private async discoverAllServers(): Promise<void> {
174174
// Load agent config
175175
const result = await loadAgentConfig(this.features.workspace, this.features.logging, this.agentPaths)
176176

@@ -221,7 +221,7 @@ export class McpManager {
221221
// Process servers in batches
222222
for (let i = 0; i < totalServers; i += MAX_CONCURRENT_SERVERS) {
223223
const batch = serversToInit.slice(i, i + MAX_CONCURRENT_SERVERS)
224-
const batchPromises = batch.map(([name, cfg]) => this.initOneServer(name, cfg, authIntent))
224+
const batchPromises = batch.map(([name, cfg]) => this.initOneServer(name, cfg, AuthIntent.Silent))
225225

226226
this.features.logging.debug(
227227
`MCP: initializing batch of ${batch.length} servers (${i + 1}-${Math.min(i + MAX_CONCURRENT_SERVERS, totalServers)} of ${totalServers})`
@@ -373,19 +373,29 @@ export class McpManager {
373373

374374
if (needsOAuth) {
375375
OAuthClient.initialize(this.features.workspace, this.features.logging)
376-
const bearer = await OAuthClient.getValidAccessToken(base, {
377-
interactive: authIntent === 'interactive',
378-
})
379-
// add authorization header if we are able to obtain a bearer token
380-
if (bearer) {
381-
headers = { ...headers, Authorization: `Bearer ${bearer}` }
382-
} else if (authIntent === 'silent') {
383-
// In silent mode we never launch a browser. If we cannot obtain a token
384-
// from cache/refresh, surface a clear auth-required error and stop here.
385-
throw new AgenticChatError(
386-
`MCP: server '${serverName}' requires OAuth. Open "Edit MCP Server" and save to sign in.`,
387-
'MCPServerAuthFailed'
388-
)
376+
try {
377+
const bearer = await OAuthClient.getValidAccessToken(base, {
378+
interactive: authIntent === AuthIntent.Interactive,
379+
})
380+
if (bearer) {
381+
headers = { ...headers, Authorization: `Bearer ${bearer}` }
382+
} else if (authIntent === AuthIntent.Silent) {
383+
throw new AgenticChatError(
384+
`MCP: server '${serverName}' requires OAuth. Open "Edit MCP Server" and save to sign in.`,
385+
'MCPServerAuthFailed'
386+
)
387+
}
388+
} catch (e: any) {
389+
const msg = e?.message || ''
390+
const short = /authorization_timed_out/i.test(msg)
391+
? 'Sign-in timed out. Please try again.'
392+
: /Authorization error|PKCE|access_denied|login|consent|token exchange failed/i.test(
393+
msg
394+
)
395+
? 'Sign-in was cancelled or failed. Please try again.'
396+
: `OAuth failed: ${msg}`
397+
398+
throw new AgenticChatError(`MCP: ${short}`, 'MCPServerAuthFailed')
389399
}
390400
}
391401

@@ -1156,15 +1166,16 @@ export class McpManager {
11561166
*/
11571167
public async removeServerFromConfigFile(serverName: string): Promise<void> {
11581168
try {
1159-
const cfg = this.mcpServers.get(serverName)
1169+
const sanitized = sanitizeName(serverName)
1170+
const cfg = this.mcpServers.get(sanitized)
11601171
if (!cfg || !cfg.__configPath__) {
11611172
this.features.logging.warn(
11621173
`Cannot remove config for server '${serverName}': Config not found or missing path`
11631174
)
11641175
return
11651176
}
11661177

1167-
const unsanitizedName = this.serverNameMapping.get(serverName) || serverName
1178+
const unsanitizedName = this.serverNameMapping.get(sanitized) || serverName
11681179

11691180
// Remove from agent config
11701181
if (unsanitizedName && this.agentConfig) {

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ describe('OAuthClient getValidAccessToken()', () => {
113113

114114
stubFileSystem(cachedToken, cachedReg)
115115

116-
const token = await OAuthClient.getValidAccessToken(new URL('https://api.example.com/mcp'))
116+
const token = await OAuthClient.getValidAccessToken(new URL('https://api.example.com/mcp'), {
117+
interactive: true,
118+
})
117119
expect(token).to.equal('cached_access')
118120
expect((http.createServer as any).calledOnce).to.be.true
119121
})

server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as path from 'path'
99
import { spawn } from 'child_process'
1010
import { URL, URLSearchParams } from 'url'
1111
import * as http from 'http'
12+
import * as os from 'os'
1213
import { Logger, Workspace } from '@aws/language-server-runtimes/server-interface'
1314

1415
interface Token {
@@ -46,9 +47,9 @@ export class OAuthClient {
4647
*/
4748
public static async getValidAccessToken(
4849
mcpBase: URL,
49-
opts: { interactive?: boolean } = { interactive: true }
50+
opts: { interactive?: boolean } = { interactive: false }
5051
): Promise<string | undefined> {
51-
const interactive = opts?.interactive !== false
52+
const interactive = opts?.interactive === true
5253
const key = this.computeKey(mcpBase)
5354
const regPath = path.join(this.cacheDir, `${key}.registration.json`)
5455
const tokPath = path.join(this.cacheDir, `${key}.token.json`)
@@ -333,6 +334,7 @@ export class OAuthClient {
333334
redirectUri: string,
334335
server: http.Server
335336
): Promise<Token> {
337+
const DEFAULT_PKCE_TIMEOUT_MS = 20_000
336338
// a) generate PKCE params
337339
const verifier = this.b64url(crypto.randomBytes(32))
338340
const challenge = this.b64url(crypto.createHash('sha256').update(verifier).digest())
@@ -353,25 +355,37 @@ export class OAuthClient {
353355

354356
const opener =
355357
process.platform === 'win32'
356-
? { cmd: 'cmd', args: ['/c', 'start', authz.toString()] }
358+
? {
359+
cmd: 'cmd',
360+
args: ['/c', 'start', '', `"${authz.toString().replace(/"/g, '""')}"`],
361+
}
357362
: process.platform === 'darwin'
358363
? { cmd: 'open', args: [authz.toString()] }
359364
: { cmd: 'xdg-open', args: [authz.toString()] }
360365

361366
void spawn(opener.cmd, opener.args, { detached: true, stdio: 'ignore' }).unref()
362367

363368
// c) wait for code on our loopback
364-
const { code, rxState, err } = await new Promise<{ code: string; rxState: string; err?: string }>(resolve => {
369+
const waitForFlow = new Promise<{ code: string; rxState: string; err?: string; errDesc?: string }>(resolve => {
365370
server.on('request', (req, res) => {
366371
const u = new URL(req.url || '/', redirectUri)
367372
const c = u.searchParams.get('code') || ''
368373
const s = u.searchParams.get('state') || ''
369374
const e = u.searchParams.get('error') || undefined
375+
const ed = u.searchParams.get('error_description') || undefined
370376
res.writeHead(200, { 'content-type': 'text/html' }).end('<h2>You may close this tab.</h2>')
371-
resolve({ code: c, rxState: s, err: e })
377+
resolve({ code: c, rxState: s, err: e, errDesc: ed })
372378
})
373379
})
374-
if (err) throw new Error(`Authorization error: ${err}`)
380+
const { code, rxState, err, errDesc } = await Promise.race([
381+
waitForFlow,
382+
new Promise<never>((_, reject) =>
383+
setTimeout(() => reject(new Error('authorization_timed_out')), DEFAULT_PKCE_TIMEOUT_MS)
384+
),
385+
])
386+
if (err) {
387+
throw new Error(`Authorization error: ${err}${errDesc ? ` - ${errDesc}` : ''}`)
388+
}
375389
if (!code || rxState !== state) throw new Error('Invalid authorization response (state mismatch)')
376390

377391
// d) exchange code for token
@@ -438,12 +452,7 @@ export class OAuthClient {
438452
}
439453

440454
/** Directory for caching registration + tokens */
441-
private static readonly cacheDir = path.join(
442-
process.env.HOME || process.env.USERPROFILE || '.',
443-
'.aws',
444-
'sso',
445-
'cache'
446-
)
455+
private static readonly cacheDir = path.join(os.homedir(), '.aws', 'sso', 'cache')
447456

448457
/**
449458
* Await server.listen() but reject if it emits 'error' (eg EADDRINUSE),

0 commit comments

Comments
 (0)