@dxheroes/local-mcp-backend 0.9.2 → 0.11.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 (56) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +52 -0
  3. package/dist/__tests__/integration/mcp-proxy-auth-http.test.js +283 -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 +171 -110
  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 +63 -2
  14. package/dist/main.js.map +1 -1
  15. package/dist/modules/auth/auth.config.js +10 -5
  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 +95 -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/health/health.controller.js +1 -1
  26. package/dist/modules/health/health.controller.js.map +1 -1
  27. package/dist/modules/mcp/mcp.service.js +48 -8
  28. package/dist/modules/mcp/mcp.service.js.map +1 -1
  29. package/dist/modules/oauth/oauth.controller.js +78 -1
  30. package/dist/modules/oauth/oauth.controller.js.map +1 -1
  31. package/dist/modules/oauth/oauth.service.js +197 -1
  32. package/dist/modules/oauth/oauth.service.js.map +1 -1
  33. package/dist/modules/proxy/proxy.controller.js +152 -27
  34. package/dist/modules/proxy/proxy.controller.js.map +1 -1
  35. package/dist/modules/proxy/proxy.service.js +28 -4
  36. package/dist/modules/proxy/proxy.service.js.map +1 -1
  37. package/docker-entrypoint.sh +15 -2
  38. package/package.json +9 -7
  39. package/src/__tests__/integration/mcp-proxy-auth-http.test.ts +311 -0
  40. package/src/__tests__/integration/oauth-authorize-callback.test.ts +155 -0
  41. package/src/__tests__/integration/proxy-auth.test.ts +151 -168
  42. package/src/__tests__/unit/auth.guard.test.ts +12 -2
  43. package/src/common/filters/all-exceptions.filter.ts +11 -0
  44. package/src/main.ts +56 -2
  45. package/src/modules/auth/auth.config.ts +9 -4
  46. package/src/modules/auth/auth.module.ts +3 -2
  47. package/src/modules/auth/auth.service.ts +2 -2
  48. package/src/modules/auth/mcp-oauth.guard.ts +102 -0
  49. package/src/modules/auth/mcp-oauth.utils.ts +80 -0
  50. package/src/modules/health/health.controller.ts +1 -1
  51. package/src/modules/mcp/mcp.service.ts +54 -12
  52. package/src/modules/oauth/oauth.controller.ts +84 -1
  53. package/src/modules/oauth/oauth.service.ts +218 -1
  54. package/src/modules/proxy/proxy.controller.ts +120 -25
  55. package/src/modules/proxy/proxy.service.ts +26 -4
  56. package/vitest.config.ts +2 -1
package/src/main.ts CHANGED
@@ -10,11 +10,17 @@ import { ConfigService } from '@nestjs/config';
10
10
  import { NestFactory } from '@nestjs/core';
11
11
  import { toNodeHandler } from 'better-auth/node';
12
12
  import compression from 'compression';
13
+ import type { NextFunction, Request, Response } from 'express';
13
14
  import helmet from 'helmet';
14
15
  import { AppModule } from './app.module.js';
15
16
  import { AllExceptionsFilter } from './common/filters/all-exceptions.filter.js';
16
17
  import { LoggingInterceptor } from './common/interceptors/logging.interceptor.js';
17
18
  import { AuthService } from './modules/auth/auth.service.js';
19
+ import {
20
+ createMcpProtectedResourceMetadata,
21
+ resolvePublicAuthBaseUrl,
22
+ resolvePublicBackendOrigin,
23
+ } from './modules/auth/mcp-oauth.utils.js';
18
24
 
19
25
  async function bootstrap() {
20
26
  // Determine log levels from environment
@@ -34,6 +40,10 @@ async function bootstrap() {
34
40
 
35
41
  // Security
36
42
  app.use(helmet());
43
+ app.use((_req: Request, res: Response, next: NextFunction) => {
44
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
45
+ next();
46
+ });
37
47
  app.use(compression());
38
48
 
39
49
  // CORS
@@ -72,12 +82,56 @@ async function bootstrap() {
72
82
  const authService = app.get(AuthService);
73
83
  const expressApp = app.getHttpAdapter().getInstance();
74
84
 
75
- const lazyAuthHandler = (req: any, res: any, next: any) => {
85
+ const lazyAuthHandler = async (req: Request, res: Response, next: NextFunction) => {
76
86
  const auth = authService.getAuth();
77
87
  if (!auth) return next();
78
- toNodeHandler(auth)(req, res);
88
+ try {
89
+ await toNodeHandler(auth)(req, res);
90
+ } catch (error: unknown) {
91
+ const isPrismaNotFound =
92
+ error instanceof Error &&
93
+ 'code' in error &&
94
+ (error as { code: string }).code === 'P2025';
95
+ if (isPrismaNotFound) {
96
+ // Stale session cookie — clear it and return 401
97
+ res.clearCookie('better-auth.session_token');
98
+ res.clearCookie('better-auth.session_token.sig');
99
+ if (!res.headersSent) {
100
+ res.status(401).json({ error: 'Session expired' });
101
+ }
102
+ return;
103
+ }
104
+ if (!res.headersSent) {
105
+ res.status(500).json({ error: 'Internal server error' });
106
+ }
107
+ }
79
108
  };
80
109
 
110
+ expressApp.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
111
+ res.json(createMcpProtectedResourceMetadata(configService));
112
+ });
113
+
114
+ // RFC 8414 – OAuth 2.0 Authorization Server Metadata
115
+ // MCP clients fetch this to discover the correct DCR endpoint (/api/auth/mcp/register)
116
+ // instead of falling back to the root /register which returns 404.
117
+ expressApp.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {
118
+ const backendOrigin = resolvePublicBackendOrigin(configService);
119
+ const authBaseUrl = resolvePublicAuthBaseUrl(configService);
120
+
121
+ res.json({
122
+ issuer: backendOrigin,
123
+ authorization_endpoint: `${authBaseUrl}/mcp/authorize`,
124
+ token_endpoint: `${authBaseUrl}/mcp/token`,
125
+ registration_endpoint: `${authBaseUrl}/mcp/register`,
126
+ jwks_uri: `${authBaseUrl}/mcp/jwks`,
127
+ response_types_supported: ['code'],
128
+ grant_types_supported: ['authorization_code', 'refresh_token'],
129
+ token_endpoint_auth_methods_supported: ['none'],
130
+ code_challenge_methods_supported: ['S256'],
131
+ scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
132
+ });
133
+ });
134
+
81
135
  expressApp.all('/api/auth/*splat', lazyAuthHandler);
82
136
  expressApp.all('/.well-known/*splat', lazyAuthHandler);
83
137
 
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * Better Auth Configuration
3
3
  *
4
- * Configures Better Auth with Prisma adapter, email+password (always),
5
- * optional Google OAuth, Organization plugin, and MCP OAuth plugin.
4
+ * Configures Better Auth with Prisma adapter, email+password (toggleable via
5
+ * AUTH_EMAIL_PASSWORD env var), optional Google OAuth, Organization plugin,
6
+ * and MCP OAuth plugin.
6
7
  * Auto-creates a default organization on user signup.
7
8
  */
8
9
 
9
10
  import type { PrismaClient } from '@dxheroes/local-mcp-database/generated/prisma';
11
+ import { ConfigService } from '@nestjs/config';
10
12
  import { betterAuth } from 'better-auth';
11
13
  import { prismaAdapter } from 'better-auth/adapters/prisma';
12
14
  import { mcp } from 'better-auth/plugins';
13
15
  import { organization } from 'better-auth/plugins/organization';
16
+ import { resolveMcpLoginPageUrl } from './mcp-oauth.utils.js';
14
17
 
15
18
  /**
16
19
  * Auth wrapper — simplified interface to avoid exporting Better Auth's deep generic types.
@@ -37,6 +40,8 @@ function toSlug(name: string): string {
37
40
 
38
41
  export function createAuth(prisma: PrismaClient): AuthInstance {
39
42
  const hasGoogle = !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET;
43
+ const emailPasswordEnabled = process.env.AUTH_EMAIL_PASSWORD !== 'false';
44
+ const configService = new ConfigService();
40
45
 
41
46
  const auth = betterAuth({
42
47
  basePath: '/api/auth',
@@ -47,7 +52,7 @@ export function createAuth(prisma: PrismaClient): AuthInstance {
47
52
  'http://localhost:3000',
48
53
  ]),
49
54
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
50
- emailAndPassword: { enabled: true },
55
+ emailAndPassword: { enabled: emailPasswordEnabled },
51
56
  ...(hasGoogle && {
52
57
  socialProviders: {
53
58
  google: {
@@ -106,7 +111,7 @@ export function createAuth(prisma: PrismaClient): AuthInstance {
106
111
  plugins: [
107
112
  organization(),
108
113
  mcp({
109
- loginPage: '/sign-in',
114
+ loginPage: resolveMcpLoginPageUrl(configService),
110
115
  }),
111
116
  ],
112
117
  });
@@ -7,10 +7,11 @@
7
7
  import { Global, Module } from '@nestjs/common';
8
8
  import { AuthGuard } from './auth.guard.js';
9
9
  import { AuthService } from './auth.service.js';
10
+ import { McpOAuthGuard } from './mcp-oauth.guard.js';
10
11
 
11
12
  @Global()
12
13
  @Module({
13
- providers: [AuthService, AuthGuard],
14
- exports: [AuthService, AuthGuard],
14
+ providers: [AuthService, AuthGuard, McpOAuthGuard],
15
+ exports: [AuthService, AuthGuard, McpOAuthGuard],
15
16
  })
16
17
  export class AuthModule {}
@@ -101,13 +101,13 @@ export class AuthService implements OnModuleInit {
101
101
  async validateMcpToken(bearerToken: string): Promise<AuthUser | null> {
102
102
  try {
103
103
  const tokenRecord = await this.prisma.oauthAccessToken.findFirst({
104
- where: { token: bearerToken },
104
+ where: { accessToken: bearerToken },
105
105
  });
106
106
 
107
107
  if (!tokenRecord || !tokenRecord.userId) return null;
108
108
 
109
109
  // Check expiration
110
- if (tokenRecord.expiresAt && new Date(tokenRecord.expiresAt) < new Date()) {
110
+ if (new Date(tokenRecord.accessTokenExpiresAt) < new Date()) {
111
111
  return null;
112
112
  }
113
113
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * MCP OAuth Guard
3
+ *
4
+ * Enforces OAuth 2.1 Bearer token authentication on MCP proxy endpoints.
5
+ * Validates Bearer tokens and returns RFC 9728-compliant
6
+ * WWW-Authenticate headers to guide MCP clients through OAuth discovery.
7
+ */
8
+
9
+ import {
10
+ CanActivate,
11
+ ExecutionContext,
12
+ Injectable,
13
+ UnauthorizedException,
14
+ } from '@nestjs/common';
15
+ import { ConfigService } from '@nestjs/config';
16
+ import type { Request } from 'express';
17
+ import { type AuthUser, AuthService } from './auth.service.js';
18
+ import { createMcpWwwAuthenticateHeader } from './mcp-oauth.utils.js';
19
+
20
+ type McpUnauthorizedException = UnauthorizedException & {
21
+ wwwAuthenticate?: string;
22
+ };
23
+
24
+ @Injectable()
25
+ export class McpOAuthGuard implements CanActivate {
26
+ constructor(
27
+ private readonly authService: AuthService,
28
+ private readonly configService: ConfigService
29
+ ) {}
30
+
31
+ async canActivate(context: ExecutionContext): Promise<boolean> {
32
+ const request = context.switchToHttp().getRequest<Request>();
33
+ const token = this.extractToken(request);
34
+
35
+ // 1. Try Bearer token (MCP clients like Claude Desktop, Cursor, etc.)
36
+ if (token) {
37
+ const user = await this.authService.validateMcpToken(token);
38
+ if (user) {
39
+ request.user = user;
40
+ return true;
41
+ }
42
+ throw this.createUnauthorizedError('Invalid or expired MCP OAuth token');
43
+ }
44
+
45
+ // 2. Try session cookie (browser UI calling /info endpoints)
46
+ const session = await this.validateSessionCookie(request);
47
+ if (session) {
48
+ request.user = session;
49
+ return true;
50
+ }
51
+
52
+ // 3. Neither worked — require Bearer token (for MCP client discovery)
53
+ throw this.createUnauthorizedError('Bearer token required');
54
+ }
55
+
56
+ /**
57
+ * Attempt to validate session cookie from the request.
58
+ * Returns the user if a valid session exists, null otherwise.
59
+ */
60
+ private async validateSessionCookie(request: Request): Promise<AuthUser | null> {
61
+ try {
62
+ const headers = new Headers();
63
+ for (const [key, value] of Object.entries(request.headers)) {
64
+ if (value) {
65
+ headers.set(key, Array.isArray(value) ? value.join(', ') : value);
66
+ }
67
+ }
68
+ const result = await this.authService.getSession(headers);
69
+ return result?.user ?? null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract Bearer token from Authorization header or access_token query param (SSE fallback).
77
+ */
78
+ private extractToken(request: Request): string | null {
79
+ const authHeader = request.headers.authorization;
80
+ if (authHeader?.startsWith('Bearer ')) {
81
+ return authHeader.slice(7);
82
+ }
83
+
84
+ // SSE connections may pass token as query parameter
85
+ const queryToken = request.query.access_token;
86
+ if (typeof queryToken === 'string' && queryToken.length > 0) {
87
+ return queryToken;
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Create UnauthorizedException with RFC 9728 WWW-Authenticate header
95
+ * pointing MCP clients to the protected resource metadata.
96
+ */
97
+ private createUnauthorizedError(message: string): UnauthorizedException {
98
+ const error = new UnauthorizedException(message) as McpUnauthorizedException;
99
+ error.wwwAuthenticate = createMcpWwwAuthenticateHeader(this.configService);
100
+ return error;
101
+ }
102
+ }
@@ -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
+ }
@@ -31,7 +31,7 @@ export class HealthController {
31
31
  @Get('auth-config')
32
32
  getAuthConfig() {
33
33
  return {
34
- emailAndPassword: true,
34
+ emailAndPassword: process.env.AUTH_EMAIL_PASSWORD !== 'false',
35
35
  google: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
36
36
  };
37
37
  }
@@ -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
  */