@@ -12,65 +12,121 @@ import { IDE, MCPServerStatus, SSEOptions } from "../..";
12
12
13
13
import http from "http" ;
14
14
import url from "url" ;
15
+ import crypto from "crypto" ;
15
16
import { GlobalContext , GlobalContextType } from "../../util/GlobalContext" ;
16
17
17
- let authenticatingMCPContext = null as {
18
- authenticatingServer : MCPServerStatus ;
19
- ide : IDE ;
20
- } | null ;
18
+ // Use a Map to support concurrent authentications for different servers
19
+ const authenticatingContexts = new Map <
20
+ string ,
21
+ {
22
+ authenticatingServer : MCPServerStatus ;
23
+ ide : IDE ;
24
+ state ?: string ;
25
+ }
26
+ > ( ) ;
27
+
28
+ // Map state parameters to server URLs for OAuth callback matching
29
+ const stateToServerUrl = new Map < string , string > ( ) ;
21
30
22
31
const PORT = 3000 ;
23
32
24
- const server = http . createServer ( ( req , res ) => {
25
- try {
26
- if ( ! req . url ) {
27
- throw new Error ( "no url found" ) ;
28
- }
33
+ let serverInstance : http . Server | null = null ;
29
34
30
- const parsedUrl = url . parse ( req . url , true ) ;
31
- if ( ! parsedUrl . query [ "code" ] ) {
32
- throw new Error ( "no query params found" ) ;
33
- }
35
+ const createServerForOAuth = ( ) =>
36
+ http . createServer ( ( req , res ) => {
37
+ try {
38
+ if ( ! req . url ) {
39
+ throw new Error ( "no url found" ) ;
40
+ }
41
+
42
+ const parsedUrl = url . parse ( req . url , true ) ;
43
+ if ( ! parsedUrl . query [ "code" ] ) {
44
+ throw new Error ( "no query params found" ) ;
45
+ }
46
+
47
+ const code = parsedUrl . query [ "code" ] as string ;
48
+ const state = parsedUrl . query [ "state" ] as string | undefined ;
34
49
35
- void handleMCPOauthCode ( parsedUrl . query [ " code" ] as string ) ;
50
+ void handleMCPOauthCode ( code , state ) ;
36
51
37
- const html = `
52
+ const html = `
38
53
<!DOCTYPE html>
39
54
<html>
40
55
<head><title>Authentication Complete</title></head>
41
56
<body>Authentication Complete. You can close this page now.</body>
42
57
</html>` ;
43
58
44
- res . writeHead ( 200 , {
45
- "Content-Type" : "text/html" ,
46
- } ) ;
47
- res . end ( html ) ;
48
- } catch ( error ) {
49
- res . writeHead ( 400 , { "Content-Type" : "text/plain" } ) ;
50
- res . end ( `Unexpected redirect error: ${ ( error as Error ) . message } ` ) ;
51
- }
52
- } ) ;
59
+ res . writeHead ( 200 , {
60
+ "Content-Type" : "text/html" ,
61
+ } ) ;
62
+ res . end ( html ) ;
63
+ } catch ( error ) {
64
+ res . writeHead ( 400 , { "Content-Type" : "text/plain" } ) ;
65
+ res . end ( `Unexpected redirect error: ${ ( error as Error ) . message } ` ) ;
66
+ }
67
+ } ) ;
53
68
54
69
type MCPOauthStorage = GlobalContextType [ "mcpOauthStorage" ] [ string ] ;
55
70
type MCPOauthStorageKey = keyof MCPOauthStorage ;
56
71
57
72
class MCPConnectionOauthProvider implements OAuthClientProvider {
58
73
private globalContext : GlobalContext ;
74
+ private _redirectUrl : string ;
75
+ private _redirectUrlInitialized : Promise < void > ;
59
76
60
77
constructor (
61
78
public oauthServerUrl : string ,
62
79
private ide : IDE ,
63
80
) {
64
81
this . globalContext = new GlobalContext ( ) ;
82
+ // Set default redirect URL immediately for synchronous access
83
+ this . _redirectUrl = `http://localhost:${ PORT } ` ;
84
+ // Initialize actual redirect URL asynchronously
85
+ this . _redirectUrlInitialized = this . _initializeRedirectUrl ( ) ;
86
+ }
87
+
88
+ private async _initializeRedirectUrl ( ) : Promise < void > {
89
+ try {
90
+ // If IDE supports getExternalUri (VS Code extension), use it for dynamic redirect URI
91
+ if ( this . ide . getExternalUri ) {
92
+ const localUri = `http://localhost:${ PORT } ` ;
93
+ const externalUri = await this . ide . getExternalUri ( localUri ) ;
94
+ this . _redirectUrl = externalUri ;
95
+ }
96
+ // Otherwise keep the default localhost URL
97
+ } catch ( error ) {
98
+ console . error ( "Failed to initialize redirect URL:" , error ) ;
99
+ // Keep default URL on error
100
+ }
65
101
}
66
102
67
103
get redirectUrl ( ) {
68
- return `http://localhost:${ PORT } ` ; // TODO: this has to be a hub url or should we spin up a server?
104
+ // Always return a valid URL, even if initialization is pending
105
+ return this . _redirectUrl ;
106
+ }
107
+
108
+ getRedirectUrlWithState ( state : string ) : string {
109
+ // Add state parameter to redirect URL
110
+ const urlObj = new URL ( this . _redirectUrl ) ;
111
+ urlObj . searchParams . set ( "state" , state ) ;
112
+ return urlObj . toString ( ) ;
113
+ }
114
+
115
+ async ensureRedirectUrl ( ) : Promise < string > {
116
+ // Wait for initialization to complete before returning
117
+ await this . _redirectUrlInitialized ;
118
+ return this . _redirectUrl ;
69
119
}
70
120
71
121
get clientMetadata ( ) {
122
+ // Generate state parameter if needed
123
+ const state = authenticatingContexts . get ( this . oauthServerUrl ) ?. state ;
124
+ const redirectUri = state
125
+ ? this . getRedirectUrlWithState ( state )
126
+ : this . redirectUrl ;
127
+
72
128
return {
73
- redirect_uris : [ this . redirectUrl ] ,
129
+ redirect_uris : [ redirectUri ] ,
74
130
token_endpoint_auth_method : "none" ,
75
131
grant_types : [ "authorization_code" , "refresh_token" ] ,
76
132
response_types : [ "code" ] ,
@@ -151,14 +207,28 @@ class MCPConnectionOauthProvider implements OAuthClientProvider {
151
207
}
152
208
153
209
async redirectToAuthorization ( authorizationUrl : URL ) {
154
- if ( ! server . listening ) {
155
- server . listen ( PORT , ( ) => {
156
- console . debug (
157
- `Server started for MCP Oauth process at http://localhost:${ PORT } /` ,
158
- ) ;
159
- } ) ;
210
+ // Ensure redirect URL is initialized before proceeding
211
+ await this . ensureRedirectUrl ( ) ;
212
+
213
+ // Only create and start local server if using localhost redirect
214
+ // For web-based VS Code, the redirect will be handled by VS Code's built-in mechanism
215
+ if ( ! this . ide . getExternalUri || this . _redirectUrl . includes ( "localhost" ) ) {
216
+ if ( ! serverInstance ) {
217
+ serverInstance = createServerForOAuth ( ) ;
218
+ }
219
+ if ( ! serverInstance . listening ) {
220
+ await new Promise < void > ( ( resolve , reject ) => {
221
+ serverInstance ! . listen ( PORT , ( ) => {
222
+ console . debug (
223
+ `Server started for MCP Oauth process at http://localhost:${ PORT } /` ,
224
+ ) ;
225
+ resolve ( ) ;
226
+ } ) ;
227
+ serverInstance ! . on ( "error" , reject ) ;
228
+ } ) ;
229
+ }
160
230
}
161
- void this . ide . openUrl ( authorizationUrl . toString ( ) ) ;
231
+ await this . ide . openUrl ( authorizationUrl . toString ( ) ) ;
162
232
}
163
233
}
164
234
@@ -175,49 +245,109 @@ export async function getOauthToken(mcpServerUrl: string, ide: IDE) {
175
245
export async function performAuth ( mcpServer : MCPServerStatus , ide : IDE ) {
176
246
const mcpServerUrl = ( mcpServer . transport as SSEOptions ) . url ;
177
247
const authProvider = new MCPConnectionOauthProvider ( mcpServerUrl , ide ) ;
178
- authenticatingMCPContext = {
248
+ // Ensure redirect URL is ready before starting auth
249
+ await authProvider . ensureRedirectUrl ( ) ;
250
+
251
+ // Generate a unique state parameter for this auth flow
252
+ const state = crypto . randomUUID ( ) ;
253
+
254
+ // Store context for this specific server with state
255
+ authenticatingContexts . set ( mcpServerUrl , {
179
256
authenticatingServer : mcpServer ,
180
257
ide,
181
- } ;
182
- return await auth ( authProvider , {
183
- serverUrl : mcpServerUrl ,
258
+ state,
184
259
} ) ;
260
+
261
+ // Map state to server URL for callback matching
262
+ stateToServerUrl . set ( state , mcpServerUrl ) ;
263
+
264
+ try {
265
+ return await auth ( authProvider , {
266
+ serverUrl : mcpServerUrl ,
267
+ } ) ;
268
+ } catch ( error ) {
269
+ // Clean up on error
270
+ authenticatingContexts . delete ( mcpServerUrl ) ;
271
+ stateToServerUrl . delete ( state ) ;
272
+ throw error ;
273
+ }
185
274
}
186
275
187
276
/**
188
277
* handle the authentication code received from the oauth redirect
189
278
*/
190
- async function handleMCPOauthCode ( authorizationCode : string ) {
191
- if ( ! authenticatingMCPContext ) {
192
- return ;
193
- }
194
- const { ide, authenticatingServer } = authenticatingMCPContext ;
195
- const serverUrl = ( authenticatingServer . transport as SSEOptions ) . url ;
279
+ async function handleMCPOauthCode ( authorizationCode : string , state ?: string ) {
280
+ let serverUrl : string | undefined ;
281
+ let context :
282
+ | { authenticatingServer : MCPServerStatus ; ide : IDE ; state ?: string }
283
+ | undefined ;
196
284
197
- if ( ! serverUrl ) {
198
- void ide . showToast ( "error" , "No MCP server url found for authentication" ) ;
199
- return ;
285
+ if ( state ) {
286
+ // Use state parameter to find the correct server
287
+ serverUrl = stateToServerUrl . get ( state ) ;
288
+ if ( serverUrl ) {
289
+ context = authenticatingContexts . get ( serverUrl ) ;
290
+ }
291
+ } else {
292
+ // Fallback: if no state or single context, use the first one
293
+ const contexts = Array . from ( authenticatingContexts . entries ( ) ) ;
294
+ if ( contexts . length === 1 ) {
295
+ [ serverUrl , context ] = contexts [ 0 ] ;
296
+ }
200
297
}
201
- if ( ! authorizationCode ) {
202
- void ide . showToast (
203
- "error" ,
204
- `No MCP authorization code found for ${ serverUrl } ` ,
205
- ) ;
298
+
299
+ if ( ! context || ! serverUrl ) {
300
+ console . error ( "No matching authenticating context found for state:" , state ) ;
206
301
return ;
207
302
}
208
- server . close ( ( ) => console . debug ( "Server for MCP Oauth process was closed" ) ) ;
209
- const authProvider = new MCPConnectionOauthProvider ( serverUrl , ide ) ;
210
- const authStatus = await auth ( authProvider , {
211
- serverUrl,
212
- authorizationCode,
213
- } ) ;
214
- if ( authStatus === "AUTHORIZED" ) {
215
- const { MCPManagerSingleton } = await import ( "./MCPManagerSingleton" ) ; // put dynamic import to avoid cyclic imports
216
- await MCPManagerSingleton . getInstance ( ) . refreshConnection (
217
- authenticatingServer . id ,
218
- ) ;
303
+
304
+ const { ide, authenticatingServer } = context ;
305
+
306
+ try {
307
+ if ( ! serverUrl ) {
308
+ throw new Error ( "No MCP server url found for authentication" ) ;
309
+ }
310
+ if ( ! authorizationCode ) {
311
+ throw new Error ( `No MCP authorization code found for ${ serverUrl } ` ) ;
312
+ }
313
+
314
+ // Close the server before processing auth
315
+ if ( serverInstance ) {
316
+ await new Promise < void > ( ( resolve ) => {
317
+ serverInstance ! . close ( ( ) => {
318
+ console . debug ( "Server for MCP Oauth process was closed" ) ;
319
+ serverInstance = null ;
320
+ resolve ( ) ;
321
+ } ) ;
322
+ } ) ;
323
+ }
324
+
325
+ const authProvider = new MCPConnectionOauthProvider ( serverUrl , ide ) ;
326
+ await authProvider . ensureRedirectUrl ( ) ;
327
+ const authStatus = await auth ( authProvider , {
328
+ serverUrl,
329
+ authorizationCode,
330
+ } ) ;
331
+
332
+ if ( authStatus === "AUTHORIZED" ) {
333
+ const { MCPManagerSingleton } = await import ( "./MCPManagerSingleton" ) ; // put dynamic import to avoid cyclic imports
334
+ await MCPManagerSingleton . getInstance ( ) . refreshConnection (
335
+ authenticatingServer . id ,
336
+ ) ;
337
+ }
338
+ } catch ( error ) {
339
+ const errorMessage = error instanceof Error ? error . message : String ( error ) ;
340
+ console . error ( "OAuth authorization failed:" , errorMessage ) ;
341
+ if ( context ?. ide ) {
342
+ await context . ide . showToast ( "error" , `OAuth failed: ${ errorMessage } ` ) ;
343
+ }
344
+ } finally {
345
+ // Always clean up the context and state mapping
346
+ authenticatingContexts . delete ( serverUrl ) ;
347
+ if ( state ) {
348
+ stateToServerUrl . delete ( state ) ;
349
+ }
219
350
}
220
- authenticatingMCPContext = null ;
221
351
}
222
352
223
353
export function removeMCPAuth ( mcpServer : MCPServerStatus , ide : IDE ) {
0 commit comments