@dxheroes/local-mcp-backend 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +27 -0
  3. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +246 -0
  4. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js.map +1 -0
  5. package/dist/__tests__/integration/oauth-authorize-callback.test.js +122 -0
  6. package/dist/__tests__/integration/oauth-authorize-callback.test.js.map +1 -0
  7. package/dist/__tests__/integration/proxy-auth.test.js +121 -111
  8. package/dist/__tests__/integration/proxy-auth.test.js.map +1 -1
  9. package/dist/__tests__/unit/auth.guard.test.js +23 -2
  10. package/dist/__tests__/unit/auth.guard.test.js.map +1 -1
  11. package/dist/common/filters/all-exceptions.filter.js +6 -0
  12. package/dist/common/filters/all-exceptions.filter.js.map +1 -1
  13. package/dist/main.js +37 -0
  14. package/dist/main.js.map +1 -1
  15. package/dist/modules/auth/auth.config.js +5 -2
  16. package/dist/modules/auth/auth.config.js.map +1 -1
  17. package/dist/modules/auth/auth.module.js +5 -2
  18. package/dist/modules/auth/auth.module.js.map +1 -1
  19. package/dist/modules/auth/auth.service.js +2 -2
  20. package/dist/modules/auth/auth.service.js.map +1 -1
  21. package/dist/modules/auth/mcp-oauth.guard.js +70 -0
  22. package/dist/modules/auth/mcp-oauth.guard.js.map +1 -0
  23. package/dist/modules/auth/mcp-oauth.utils.js +75 -0
  24. package/dist/modules/auth/mcp-oauth.utils.js.map +1 -0
  25. package/dist/modules/mcp/mcp.service.js +48 -8
  26. package/dist/modules/mcp/mcp.service.js.map +1 -1
  27. package/dist/modules/oauth/oauth.controller.js +78 -1
  28. package/dist/modules/oauth/oauth.controller.js.map +1 -1
  29. package/dist/modules/oauth/oauth.service.js +197 -1
  30. package/dist/modules/oauth/oauth.service.js.map +1 -1
  31. package/dist/modules/proxy/proxy.controller.js +152 -27
  32. package/dist/modules/proxy/proxy.controller.js.map +1 -1
  33. package/dist/modules/proxy/proxy.service.js +28 -4
  34. package/dist/modules/proxy/proxy.service.js.map +1 -1
  35. package/docker-entrypoint.sh +15 -2
  36. package/package.json +7 -7
  37. package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +281 -0
  38. package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
  39. package/src/__tests__/integration/proxy-auth.test.ts +119 -168
  40. package/src/__tests__/unit/auth.guard.test.ts +12 -2
  41. package/src/common/filters/all-exceptions.filter.ts +11 -0
  42. package/src/main.ts +32 -1
  43. package/src/modules/auth/auth.config.ts +4 -1
  44. package/src/modules/auth/auth.module.ts +3 -2
  45. package/src/modules/auth/auth.service.ts +2 -2
  46. package/src/modules/auth/mcp-oauth.guard.ts +75 -0
  47. package/src/modules/auth/mcp-oauth.utils.ts +80 -0
  48. package/src/modules/mcp/mcp.service.ts +54 -12
  49. package/src/modules/oauth/oauth.controller.ts +84 -1
  50. package/src/modules/oauth/oauth.service.ts +218 -1
  51. package/src/modules/proxy/proxy.controller.ts +120 -25
  52. package/src/modules/proxy/proxy.service.ts +26 -4
  53. package/vitest.config.ts +2 -1
@@ -0,0 +1,80 @@
1
+ import type { ConfigService } from '@nestjs/config';
2
+
3
+ const DEFAULT_BACKEND_PORT = '3001';
4
+ const DEFAULT_FRONTEND_PORT = '3000';
5
+
6
+ function trimTrailingSlash(value: string): string {
7
+ return value.replace(/\/+$/, '');
8
+ }
9
+
10
+ function swapPort(origin: string, fromPort: string, toPort: string): string {
11
+ const url = new URL(origin);
12
+ if (url.port === fromPort) {
13
+ url.port = toPort;
14
+ }
15
+ return trimTrailingSlash(url.origin);
16
+ }
17
+
18
+ export function resolvePublicBackendOrigin(configService: ConfigService): string {
19
+ const configuredUrl = configService.get<string>('BETTER_AUTH_URL');
20
+ if (configuredUrl) {
21
+ return trimTrailingSlash(new URL(configuredUrl).origin);
22
+ }
23
+
24
+ const port = configService.get<number>('app.port') || Number(DEFAULT_BACKEND_PORT);
25
+ return `http://localhost:${port}`;
26
+ }
27
+
28
+ export function resolvePublicAuthBaseUrl(configService: ConfigService): string {
29
+ return `${resolvePublicBackendOrigin(configService)}/api/auth`;
30
+ }
31
+
32
+ export function resolveFrontendOrigin(configService: ConfigService): string {
33
+ const configuredUrl = configService.get<string>('FRONTEND_URL');
34
+ if (configuredUrl) {
35
+ return trimTrailingSlash(new URL(configuredUrl).origin);
36
+ }
37
+
38
+ const backendOrigin = resolvePublicBackendOrigin(configService);
39
+ const backendUrl = new URL(backendOrigin);
40
+
41
+ if (backendUrl.port === '9631') {
42
+ return swapPort(backendOrigin, '9631', '9630');
43
+ }
44
+
45
+ if (backendUrl.port === DEFAULT_BACKEND_PORT) {
46
+ return swapPort(backendOrigin, DEFAULT_BACKEND_PORT, DEFAULT_FRONTEND_PORT);
47
+ }
48
+
49
+ return trimTrailingSlash(backendUrl.origin);
50
+ }
51
+
52
+ export function resolveMcpLoginPageUrl(configService: ConfigService): string {
53
+ return `${resolveFrontendOrigin(configService)}/sign-in`;
54
+ }
55
+
56
+ export function createMcpProtectedResourceMetadata(configService: ConfigService) {
57
+ const backendOrigin = resolvePublicBackendOrigin(configService);
58
+ const authBaseUrl = resolvePublicAuthBaseUrl(configService);
59
+
60
+ return {
61
+ resource: `${backendOrigin}/api/mcp`,
62
+ authorization_servers: [backendOrigin],
63
+ bearer_methods_supported: ['header'],
64
+ scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
65
+ jwks_uri: `${authBaseUrl}/mcp/jwks`,
66
+ resource_signing_alg_values_supported: ['RS256', 'none'],
67
+ };
68
+ }
69
+
70
+ export function createMcpWwwAuthenticateHeader(configService: ConfigService): string {
71
+ const resourceMetadataUrl = `${resolvePublicBackendOrigin(
72
+ configService
73
+ )}/.well-known/oauth-protected-resource`;
74
+
75
+ return [
76
+ 'Bearer',
77
+ `resource_metadata="${resourceMetadataUrl}"`,
78
+ `resource_metadata_uri="${resourceMetadataUrl}"`,
79
+ ].join(' ');
80
+ }
@@ -254,11 +254,11 @@ export class McpService {
254
254
 
255
255
  // For remote_http servers, connect and fetch tools
256
256
  if (server.type === 'remote_http') {
257
- const config = this.parseConfig(server.config) as { url: string };
257
+ const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
258
258
  const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
259
259
 
260
260
  const remoteServer = new RemoteHttpMcpServer(
261
- { url: config.url, transport: 'http' },
261
+ { url: config.url, transport: 'http', headers: config.headers },
262
262
  null,
263
263
  apiKeyConfig
264
264
  );
@@ -269,11 +269,11 @@ export class McpService {
269
269
 
270
270
  // For remote_sse servers, connect and fetch tools
271
271
  if (server.type === 'remote_sse') {
272
- const config = this.parseConfig(server.config) as { url: string };
272
+ const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
273
273
  const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
274
274
 
275
275
  const remoteServer = new RemoteSseMcpServer(
276
- { url: config.url, transport: 'sse' },
276
+ { url: config.url, transport: 'sse', headers: config.headers },
277
277
  null,
278
278
  apiKeyConfig
279
279
  );
@@ -410,14 +410,30 @@ export class McpService {
410
410
  }
411
411
 
412
412
  // For remote_http servers, validate by connecting
413
+ let oauthRequired = false;
413
414
  if (server.type === 'remote_http' && status === 'unknown') {
414
- const config = this.parseConfig(server.config) as { url: string };
415
+ const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
415
416
  const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
416
417
 
418
+ // Get OAuth token if available, mapping Prisma types to core OAuthToken
419
+ const oauthToken = server.oauthToken
420
+ ? {
421
+ id: server.oauthToken.id,
422
+ mcpServerId: server.oauthToken.mcpServerId,
423
+ accessToken: server.oauthToken.accessToken,
424
+ tokenType: server.oauthToken.tokenType,
425
+ refreshToken: server.oauthToken.refreshToken ?? undefined,
426
+ scope: server.oauthToken.scope ?? undefined,
427
+ expiresAt: server.oauthToken.expiresAt?.getTime(),
428
+ createdAt: server.oauthToken.createdAt.getTime(),
429
+ updatedAt: server.oauthToken.updatedAt.getTime(),
430
+ }
431
+ : null;
432
+
417
433
  try {
418
434
  const remoteServer = new RemoteHttpMcpServer(
419
- { url: config.url, transport: 'http' },
420
- null,
435
+ { url: config.url, transport: 'http', headers: config.headers },
436
+ oauthToken,
421
437
  apiKeyConfig
422
438
  );
423
439
  await remoteServer.initialize();
@@ -427,19 +443,39 @@ export class McpService {
427
443
  } catch (error) {
428
444
  status = 'error';
429
445
  validationError = error instanceof Error ? error.message : 'Unknown error';
430
- validationDetails = `Connection failed: ${validationError}`;
446
+ if (validationError.includes('OAUTH_REQUIRED')) {
447
+ oauthRequired = true;
448
+ validationDetails = 'OAuth authentication required. Click "Login with OAuth" to authorize.';
449
+ } else {
450
+ validationDetails = `Connection failed: ${validationError}`;
451
+ }
431
452
  }
432
453
  }
433
454
 
434
455
  // For remote_sse servers, validate by connecting
435
456
  if (server.type === 'remote_sse' && status === 'unknown') {
436
- const config = this.parseConfig(server.config) as { url: string };
457
+ const config = this.parseConfig(server.config) as { url: string; headers?: Record<string, string> };
437
458
  const apiKeyConfig = server.apiKeyConfig ? JSON.parse(server.apiKeyConfig as string) : null;
438
459
 
460
+ // Get OAuth token if available, mapping Prisma types to core OAuthToken
461
+ const oauthToken = server.oauthToken
462
+ ? {
463
+ id: server.oauthToken.id,
464
+ mcpServerId: server.oauthToken.mcpServerId,
465
+ accessToken: server.oauthToken.accessToken,
466
+ tokenType: server.oauthToken.tokenType,
467
+ refreshToken: server.oauthToken.refreshToken ?? undefined,
468
+ scope: server.oauthToken.scope ?? undefined,
469
+ expiresAt: server.oauthToken.expiresAt?.getTime(),
470
+ createdAt: server.oauthToken.createdAt.getTime(),
471
+ updatedAt: server.oauthToken.updatedAt.getTime(),
472
+ }
473
+ : null;
474
+
439
475
  try {
440
476
  const remoteServer = new RemoteSseMcpServer(
441
- { url: config.url, transport: 'sse' },
442
- null,
477
+ { url: config.url, transport: 'sse', headers: config.headers },
478
+ oauthToken,
443
479
  apiKeyConfig
444
480
  );
445
481
  await remoteServer.initialize();
@@ -449,7 +485,12 @@ export class McpService {
449
485
  } catch (error) {
450
486
  status = 'error';
451
487
  validationError = error instanceof Error ? error.message : 'Unknown error';
452
- validationDetails = `Connection failed: ${validationError}`;
488
+ if (validationError.includes('OAUTH_REQUIRED')) {
489
+ oauthRequired = true;
490
+ validationDetails = 'OAuth authentication required. Click "Login with OAuth" to authorize.';
491
+ } else {
492
+ validationDetails = `Connection failed: ${validationError}`;
493
+ }
453
494
  }
454
495
  }
455
496
 
@@ -493,6 +534,7 @@ export class McpService {
493
534
  hasOAuth,
494
535
  requiresApiKey,
495
536
  requiresOAuth,
537
+ oauthRequired,
496
538
  isReady,
497
539
  status,
498
540
  error: validationError,
@@ -4,7 +4,21 @@
4
4
  * REST API endpoints for OAuth token management.
5
5
  */
6
6
 
7
- import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
7
+ import {
8
+ Body,
9
+ Controller,
10
+ Delete,
11
+ Get,
12
+ HttpCode,
13
+ HttpStatus,
14
+ Logger,
15
+ Param,
16
+ Post,
17
+ Query,
18
+ Req,
19
+ Res,
20
+ } from '@nestjs/common';
21
+ import type { Request, Response } from 'express';
8
22
  import { SkipOrgCheck } from '../auth/decorators/skip-org-check.decorator.js';
9
23
  import { OAuthService } from './oauth.service.js';
10
24
 
@@ -36,8 +50,77 @@ interface StoreTokenDto {
36
50
  @SkipOrgCheck()
37
51
  @Controller('oauth')
38
52
  export class OAuthController {
53
+ private readonly logger = new Logger(OAuthController.name);
54
+
39
55
  constructor(private readonly oauthService: OAuthService) {}
40
56
 
57
+ /**
58
+ * Start OAuth auto-discovery flow for an MCP server.
59
+ * Discovers OAuth endpoints via RFC 9728/8414, performs DCR if needed,
60
+ * and redirects the browser to the authorization server.
61
+ */
62
+ @Get('authorize/:serverId')
63
+ async authorize(
64
+ @Param('serverId') serverId: string,
65
+ @Req() req: Request,
66
+ @Res() res: Response
67
+ ) {
68
+ try {
69
+ const callbackUrl = `${req.protocol}://${req.get('host')}/api/oauth/callback`;
70
+ const authorizationUrl = await this.oauthService.discoverAndAuthorize(serverId, callbackUrl);
71
+ res.redirect(authorizationUrl);
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : 'Unknown error';
74
+ this.logger.error(`OAuth authorize failed for ${serverId}: ${message}`);
75
+ res.status(400).send(this.renderCallbackPage(false, message));
76
+ }
77
+ }
78
+
79
+ /**
80
+ * OAuth callback endpoint. Receives authorization code, exchanges for tokens,
81
+ * and returns an HTML page that notifies the opener window.
82
+ */
83
+ @Get('callback')
84
+ async callback(
85
+ @Query('code') code: string,
86
+ @Query('state') state: string,
87
+ @Req() req: Request,
88
+ @Res() res: Response
89
+ ) {
90
+ if (!code || !state) {
91
+ res.status(400).send(this.renderCallbackPage(false, 'Missing code or state parameter'));
92
+ return;
93
+ }
94
+
95
+ const serverId = state;
96
+ const callbackUrl = `${req.protocol}://${req.get('host')}/api/oauth/callback`;
97
+
98
+ const result = await this.oauthService.handleCallback(serverId, code, callbackUrl);
99
+ res.status(200).send(this.renderCallbackPage(result.success, result.error));
100
+ }
101
+
102
+ /**
103
+ * Render an HTML page that posts a message to the opener window and closes itself.
104
+ */
105
+ private renderCallbackPage(success: boolean, error?: string): string {
106
+ const message = JSON.stringify({
107
+ type: 'oauth-callback',
108
+ success,
109
+ error: error || null,
110
+ });
111
+ return `<!DOCTYPE html>
112
+ <html><head><title>OAuth ${success ? 'Success' : 'Error'}</title></head>
113
+ <body>
114
+ <p>${success ? 'Authorization successful! This window will close.' : `Error: ${error}`}</p>
115
+ <script>
116
+ if (window.opener) {
117
+ window.opener.postMessage(${message}, '*');
118
+ }
119
+ setTimeout(function() { window.close(); }, 2000);
120
+ </script>
121
+ </body></html>`;
122
+ }
123
+
41
124
  /**
42
125
  * Start OAuth flow for an MCP server
43
126
  */
@@ -4,7 +4,8 @@
4
4
  * Manages OAuth tokens for MCP servers.
5
5
  */
6
6
 
7
- import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
7
+ import { OAuthDiscoveryService } from '@dxheroes/local-mcp-core';
8
+ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
8
9
  import { PrismaService } from '../database/prisma.service.js';
9
10
 
10
11
  interface StartOAuthFlowDto {
@@ -35,8 +36,10 @@ interface StoreTokenDto {
35
36
 
36
37
  @Injectable()
37
38
  export class OAuthService {
39
+ private readonly logger = new Logger(OAuthService.name);
38
40
  // In-memory storage for PKCE verifiers (in production, use Redis or similar)
39
41
  private pkceVerifiers = new Map<string, { verifier: string; expiresAt: number }>();
42
+ private readonly discoveryService = new OAuthDiscoveryService();
40
43
 
41
44
  constructor(private readonly prisma: PrismaService) {}
42
45
 
@@ -183,6 +186,220 @@ export class OAuthService {
183
186
  });
184
187
  }
185
188
 
189
+ /**
190
+ * Discover OAuth configuration and build an authorization URL for a server.
191
+ * Uses RFC 9728 / 8414 / 7591 auto-discovery and optional DCR.
192
+ * Returns an authorization URL the browser should be redirected to.
193
+ */
194
+ async discoverAndAuthorize(serverId: string, callbackUrl: string): Promise<string> {
195
+ const server = await this.prisma.mcpServer.findUnique({ where: { id: serverId } });
196
+ if (!server) {
197
+ throw new NotFoundException(`MCP server ${serverId} not found`);
198
+ }
199
+
200
+ // Parse server URL from config
201
+ const config = typeof server.config === 'string' ? JSON.parse(server.config) : server.config;
202
+ const serverUrl = config?.url as string | undefined;
203
+ if (!serverUrl) {
204
+ throw new BadRequestException('Server has no URL configured');
205
+ }
206
+
207
+ // Parse existing oauthConfig if any (may have manual clientId)
208
+ const existingOAuthConfig = server.oauthConfig
209
+ ? typeof server.oauthConfig === 'string'
210
+ ? JSON.parse(server.oauthConfig)
211
+ : server.oauthConfig
212
+ : null;
213
+
214
+ // Step 1: Discover OAuth endpoints via RFC 9728 + 8414
215
+ this.logger.log(`Discovering OAuth for server ${serverId} at ${serverUrl}`);
216
+ const discovery = await this.discoveryService.discoverFromServerUrl(serverUrl);
217
+
218
+ // Step 2: Get or register client
219
+ let clientId: string;
220
+
221
+ if (existingOAuthConfig?.clientId) {
222
+ // Use manually configured clientId
223
+ clientId = existingOAuthConfig.clientId;
224
+ } else {
225
+ // Check for existing DCR registration
226
+ const existingReg = await this.prisma.oAuthClientRegistration.findUnique({
227
+ where: {
228
+ mcpServerId_authorizationServerUrl: {
229
+ mcpServerId: serverId,
230
+ authorizationServerUrl: discovery.authorizationServerUrl,
231
+ },
232
+ },
233
+ });
234
+
235
+ if (existingReg) {
236
+ // Delete stale registration — redirect_uri may have changed
237
+ await this.prisma.oAuthClientRegistration.delete({
238
+ where: { id: existingReg.id },
239
+ });
240
+ this.logger.log(`Deleted stale DCR registration for ${serverId}, will re-register`);
241
+ }
242
+
243
+ if (discovery.registrationEndpoint) {
244
+ // Perform Dynamic Client Registration (RFC 7591)
245
+ this.logger.log(`Performing DCR at ${discovery.registrationEndpoint}`);
246
+ const registration = await this.discoveryService.registerClient(
247
+ discovery.registrationEndpoint,
248
+ callbackUrl,
249
+ discovery.scopes
250
+ );
251
+ clientId = registration.clientId;
252
+
253
+ // Store registration
254
+ await this.prisma.oAuthClientRegistration.create({
255
+ data: {
256
+ mcpServerId: serverId,
257
+ authorizationServerUrl: discovery.authorizationServerUrl,
258
+ clientId: registration.clientId,
259
+ clientSecret: registration.clientSecret,
260
+ registrationAccessToken: registration.registrationAccessToken,
261
+ },
262
+ });
263
+ } else {
264
+ throw new BadRequestException(
265
+ 'No clientId configured and server does not support Dynamic Client Registration. ' +
266
+ 'Please configure a clientId manually in the OAuth settings.'
267
+ );
268
+ }
269
+ }
270
+
271
+ // Step 3: Store discovery result in oauthConfig on the server
272
+ await this.prisma.mcpServer.update({
273
+ where: { id: serverId },
274
+ data: {
275
+ oauthConfig: JSON.stringify({
276
+ authorizationServerUrl: discovery.authorizationServerUrl,
277
+ authorizationEndpoint: discovery.authorizationEndpoint,
278
+ tokenEndpoint: discovery.tokenEndpoint,
279
+ registrationEndpoint: discovery.registrationEndpoint,
280
+ resource: discovery.resource,
281
+ scopes: discovery.scopes,
282
+ clientId,
283
+ requiresOAuth: true,
284
+ }),
285
+ },
286
+ });
287
+
288
+ // Step 4: Generate PKCE
289
+ const codeVerifier = this.generateCodeVerifier();
290
+ const codeChallenge = await this.generateCodeChallenge(codeVerifier);
291
+
292
+ // Store verifier (expires in 10 minutes)
293
+ this.pkceVerifiers.set(serverId, {
294
+ verifier: codeVerifier,
295
+ expiresAt: Date.now() + 10 * 60 * 1000,
296
+ });
297
+
298
+ // Step 5: Build authorization URL
299
+ const params = new URLSearchParams({
300
+ response_type: 'code',
301
+ client_id: clientId,
302
+ redirect_uri: callbackUrl,
303
+ code_challenge: codeChallenge,
304
+ code_challenge_method: 'S256',
305
+ state: serverId, // Use serverId as state for callback routing
306
+ });
307
+
308
+ if (discovery.scopes.length > 0) {
309
+ params.set('scope', discovery.scopes.join(' '));
310
+ }
311
+
312
+ if (discovery.resource) {
313
+ params.set('resource', discovery.resource);
314
+ }
315
+
316
+ return `${discovery.authorizationEndpoint}?${params.toString()}`;
317
+ }
318
+
319
+ /**
320
+ * Handle OAuth callback: exchange authorization code for tokens.
321
+ */
322
+ async handleCallback(
323
+ serverId: string,
324
+ code: string,
325
+ callbackUrl: string
326
+ ): Promise<{ success: boolean; error?: string }> {
327
+ const server = await this.prisma.mcpServer.findUnique({ where: { id: serverId } });
328
+ if (!server) {
329
+ return { success: false, error: `MCP server ${serverId} not found` };
330
+ }
331
+
332
+ // Retrieve PKCE verifier
333
+ const pkceEntry = this.pkceVerifiers.get(serverId);
334
+ if (!pkceEntry) {
335
+ return { success: false, error: 'PKCE verifier not found or expired' };
336
+ }
337
+ if (pkceEntry.expiresAt < Date.now()) {
338
+ this.pkceVerifiers.delete(serverId);
339
+ return { success: false, error: 'PKCE verifier expired' };
340
+ }
341
+ const codeVerifier = pkceEntry.verifier;
342
+ this.pkceVerifiers.delete(serverId);
343
+
344
+ // Get oauthConfig for token endpoint and clientId
345
+ const oauthConfig = server.oauthConfig
346
+ ? typeof server.oauthConfig === 'string'
347
+ ? JSON.parse(server.oauthConfig)
348
+ : server.oauthConfig
349
+ : null;
350
+
351
+ if (!oauthConfig?.tokenEndpoint || !oauthConfig?.clientId) {
352
+ return { success: false, error: 'OAuth configuration incomplete (missing tokenEndpoint or clientId)' };
353
+ }
354
+
355
+ try {
356
+ // Exchange code for tokens
357
+ const response = await fetch(oauthConfig.tokenEndpoint, {
358
+ method: 'POST',
359
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
360
+ body: new URLSearchParams({
361
+ grant_type: 'authorization_code',
362
+ code,
363
+ redirect_uri: callbackUrl,
364
+ client_id: oauthConfig.clientId,
365
+ code_verifier: codeVerifier,
366
+ }),
367
+ });
368
+
369
+ if (!response.ok) {
370
+ const errorText = await response.text();
371
+ return { success: false, error: `Token exchange failed: ${errorText}` };
372
+ }
373
+
374
+ const tokenData = (await response.json()) as {
375
+ access_token: string;
376
+ refresh_token?: string;
377
+ token_type?: string;
378
+ scope?: string;
379
+ expires_in?: number;
380
+ };
381
+
382
+ const expiresAt = tokenData.expires_in
383
+ ? new Date(Date.now() + tokenData.expires_in * 1000)
384
+ : null;
385
+
386
+ // Store token
387
+ await this.storeToken({
388
+ mcpServerId: serverId,
389
+ accessToken: tokenData.access_token,
390
+ refreshToken: tokenData.refresh_token,
391
+ tokenType: tokenData.token_type || 'Bearer',
392
+ scope: tokenData.scope,
393
+ expiresAt,
394
+ });
395
+
396
+ return { success: true };
397
+ } catch (error) {
398
+ const message = error instanceof Error ? error.message : 'Unknown error';
399
+ return { success: false, error: message };
400
+ }
401
+ }
402
+
186
403
  /**
187
404
  * Generate a random code verifier for PKCE
188
405
  */