mcp4openapi 0.1.0 → 0.2.1

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 (152) hide show
  1. package/README.md +137 -95
  2. package/dist/scripts/validate-profile.js +3 -3
  3. package/dist/scripts/validate-profile.js.map +1 -1
  4. package/dist/src/composite-executor.d.ts +3 -1
  5. package/dist/src/composite-executor.d.ts.map +1 -1
  6. package/dist/src/composite-executor.js +16 -5
  7. package/dist/src/composite-executor.js.map +1 -1
  8. package/dist/src/constants.d.ts +49 -0
  9. package/dist/src/constants.d.ts.map +1 -1
  10. package/dist/src/constants.js +49 -0
  11. package/dist/src/constants.js.map +1 -1
  12. package/dist/src/errors.d.ts +6 -0
  13. package/dist/src/errors.d.ts.map +1 -1
  14. package/dist/src/errors.js +13 -0
  15. package/dist/src/errors.js.map +1 -1
  16. package/dist/src/generated-schemas.d.ts +832 -52
  17. package/dist/src/generated-schemas.d.ts.map +1 -1
  18. package/dist/src/generated-schemas.js +31 -8
  19. package/dist/src/generated-schemas.js.map +1 -1
  20. package/dist/src/http-client-factory.d.ts.map +1 -1
  21. package/dist/src/http-client-factory.js +14 -3
  22. package/dist/src/http-client-factory.js.map +1 -1
  23. package/dist/src/http-transport.d.ts +65 -0
  24. package/dist/src/http-transport.d.ts.map +1 -1
  25. package/dist/src/http-transport.js +921 -77
  26. package/dist/src/http-transport.js.map +1 -1
  27. package/dist/src/index.js +108 -8
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/interceptors.d.ts +3 -0
  30. package/dist/src/interceptors.d.ts.map +1 -1
  31. package/dist/src/interceptors.js +76 -8
  32. package/dist/src/interceptors.js.map +1 -1
  33. package/dist/src/logger.d.ts +1 -1
  34. package/dist/src/logger.js +3 -3
  35. package/dist/src/logger.js.map +1 -1
  36. package/dist/src/mcp-server.d.ts +33 -0
  37. package/dist/src/mcp-server.d.ts.map +1 -1
  38. package/dist/src/mcp-server.js +263 -54
  39. package/dist/src/mcp-server.js.map +1 -1
  40. package/dist/src/oauth-provider.d.ts +92 -0
  41. package/dist/src/oauth-provider.d.ts.map +1 -0
  42. package/dist/src/oauth-provider.js +588 -0
  43. package/dist/src/oauth-provider.js.map +1 -0
  44. package/dist/src/openapi-parser.d.ts +16 -0
  45. package/dist/src/openapi-parser.d.ts.map +1 -1
  46. package/dist/src/openapi-parser.js +141 -6
  47. package/dist/src/openapi-parser.js.map +1 -1
  48. package/dist/src/profile-loader.d.ts +2 -2
  49. package/dist/src/profile-loader.d.ts.map +1 -1
  50. package/dist/src/profile-loader.js +45 -24
  51. package/dist/src/profile-loader.js.map +1 -1
  52. package/dist/src/testing/fixtures.d.ts +189 -0
  53. package/dist/src/testing/fixtures.d.ts.map +1 -1
  54. package/dist/src/testing/fixtures.js +144 -0
  55. package/dist/src/testing/fixtures.js.map +1 -1
  56. package/dist/src/testing/mock-gitlab-server.d.ts +26 -17
  57. package/dist/src/testing/mock-gitlab-server.d.ts.map +1 -1
  58. package/dist/src/testing/mock-gitlab-server.js +567 -304
  59. package/dist/src/testing/mock-gitlab-server.js.map +1 -1
  60. package/dist/src/types/http-transport.d.ts +16 -0
  61. package/dist/src/types/http-transport.d.ts.map +1 -1
  62. package/dist/src/types/openapi.d.ts +5 -0
  63. package/dist/src/types/openapi.d.ts.map +1 -1
  64. package/dist/src/types/profile.d.ts +112 -3
  65. package/dist/src/types/profile.d.ts.map +1 -1
  66. package/dist/src/validation-utils.d.ts +12 -0
  67. package/dist/src/validation-utils.d.ts.map +1 -1
  68. package/dist/src/validation-utils.js +17 -0
  69. package/dist/src/validation-utils.js.map +1 -1
  70. package/package.json +12 -3
  71. package/profile-schema.json +169 -7
  72. package/dist/composite-executor.d.ts +0 -65
  73. package/dist/composite-executor.d.ts.map +0 -1
  74. package/dist/composite-executor.js +0 -147
  75. package/dist/composite-executor.js.map +0 -1
  76. package/dist/constants.d.ts +0 -36
  77. package/dist/constants.d.ts.map +0 -1
  78. package/dist/constants.js +0 -36
  79. package/dist/constants.js.map +0 -1
  80. package/dist/http-transport.d.ts +0 -195
  81. package/dist/http-transport.d.ts.map +0 -1
  82. package/dist/http-transport.js +0 -760
  83. package/dist/http-transport.js.map +0 -1
  84. package/dist/interceptors.d.ts +0 -74
  85. package/dist/interceptors.d.ts.map +0 -1
  86. package/dist/interceptors.js +0 -220
  87. package/dist/interceptors.js.map +0 -1
  88. package/dist/logger.d.ts +0 -81
  89. package/dist/logger.d.ts.map +0 -1
  90. package/dist/logger.js +0 -264
  91. package/dist/logger.js.map +0 -1
  92. package/dist/mcp-server.d.ts +0 -110
  93. package/dist/mcp-server.d.ts.map +0 -1
  94. package/dist/mcp-server.js +0 -568
  95. package/dist/mcp-server.js.map +0 -1
  96. package/dist/metrics.d.ts +0 -86
  97. package/dist/metrics.d.ts.map +0 -1
  98. package/dist/metrics.js +0 -229
  99. package/dist/metrics.js.map +0 -1
  100. package/dist/openapi-parser.d.ts +0 -35
  101. package/dist/openapi-parser.d.ts.map +0 -1
  102. package/dist/openapi-parser.js +0 -160
  103. package/dist/openapi-parser.js.map +0 -1
  104. package/dist/profile-loader.d.ts +0 -25
  105. package/dist/profile-loader.d.ts.map +0 -1
  106. package/dist/profile-loader.js +0 -134
  107. package/dist/profile-loader.js.map +0 -1
  108. package/dist/schema-validator.d.ts +0 -32
  109. package/dist/schema-validator.d.ts.map +0 -1
  110. package/dist/schema-validator.js +0 -126
  111. package/dist/schema-validator.js.map +0 -1
  112. package/dist/testing/fixtures.d.ts +0 -186
  113. package/dist/testing/fixtures.d.ts.map +0 -1
  114. package/dist/testing/fixtures.js +0 -135
  115. package/dist/testing/fixtures.js.map +0 -1
  116. package/dist/testing/http-integration.test.d.ts +0 -7
  117. package/dist/testing/http-integration.test.d.ts.map +0 -1
  118. package/dist/testing/http-integration.test.js +0 -383
  119. package/dist/testing/http-integration.test.js.map +0 -1
  120. package/dist/testing/http-multiuser.test.d.ts +0 -10
  121. package/dist/testing/http-multiuser.test.d.ts.map +0 -1
  122. package/dist/testing/http-multiuser.test.js +0 -255
  123. package/dist/testing/http-multiuser.test.js.map +0 -1
  124. package/dist/testing/integration.test.d.ts +0 -8
  125. package/dist/testing/integration.test.d.ts.map +0 -1
  126. package/dist/testing/integration.test.js +0 -247
  127. package/dist/testing/integration.test.js.map +0 -1
  128. package/dist/testing/mock-gitlab-server.d.ts +0 -34
  129. package/dist/testing/mock-gitlab-server.d.ts.map +0 -1
  130. package/dist/testing/mock-gitlab-server.js +0 -224
  131. package/dist/testing/mock-gitlab-server.js.map +0 -1
  132. package/dist/testing/test-types.d.ts +0 -59
  133. package/dist/testing/test-types.d.ts.map +0 -1
  134. package/dist/testing/test-types.js +0 -7
  135. package/dist/testing/test-types.js.map +0 -1
  136. package/dist/tool-generator.d.ts +0 -43
  137. package/dist/tool-generator.d.ts.map +0 -1
  138. package/dist/tool-generator.js +0 -123
  139. package/dist/tool-generator.js.map +0 -1
  140. package/dist/tsconfig.tsbuildinfo +0 -1
  141. package/dist/types/http-transport.d.ts +0 -39
  142. package/dist/types/http-transport.d.ts.map +0 -1
  143. package/dist/types/http-transport.js +0 -8
  144. package/dist/types/http-transport.js.map +0 -1
  145. package/dist/types/openapi.d.ts +0 -50
  146. package/dist/types/openapi.d.ts.map +0 -1
  147. package/dist/types/openapi.js +0 -9
  148. package/dist/types/openapi.js.map +0 -1
  149. package/dist/types/profile.d.ts +0 -76
  150. package/dist/types/profile.d.ts.map +0 -1
  151. package/dist/types/profile.js +0 -9
  152. package/dist/types/profile.js.map +0 -1
@@ -8,10 +8,15 @@
8
8
  * and resumability for reliable communication over HTTP.
9
9
  */
10
10
  import express from 'express';
11
+ import https from 'https';
12
+ import fs from 'fs';
11
13
  import crypto from 'crypto';
12
14
  import rateLimit from 'express-rate-limit';
13
15
  import { isInitializeRequest } from './jsonrpc-validator.js';
14
16
  import { MetricsCollector } from './metrics.js';
17
+ import { ExternalOAuthProvider } from './oauth-provider.js';
18
+ import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from './constants.js';
19
+ import { escapeHtmlSafe } from './validation-utils.js';
15
20
  // Default maximum token length (1000 characters)
16
21
  const DEFAULT_MAX_TOKEN_LENGTH = 1000;
17
22
  export class HttpTransport {
@@ -23,6 +28,10 @@ export class HttpTransport {
23
28
  metrics = null;
24
29
  cleanupInterval = null;
25
30
  messageHandler = null;
31
+ oauthProvider = null;
32
+ // Map access_token -> { refreshToken, expiresAt, clientId, scopes }
33
+ // Used to bridge /oauth/token endpoint (where we see OAuthTokens) and session initialization (where we only see access token)
34
+ oauthTokensByAccessToken = new Map();
26
35
  constructor(config, logger) {
27
36
  this.config = config;
28
37
  this.logger = logger;
@@ -33,6 +42,20 @@ export class HttpTransport {
33
42
  prefix: 'mcp_',
34
43
  });
35
44
  }
45
+ // Initialize OAuth provider if configured
46
+ if (config.oauthConfig) {
47
+ this.logger.info('Initializing OAuth provider with config', { hasClientId: !!config.oauthConfig.client_id });
48
+ this.oauthProvider = new ExternalOAuthProvider(config.oauthConfig, logger);
49
+ // Note: authorizationEndpoint may be undefined at this point if config uses issuer-based discovery
50
+ // It will be resolved lazily on first OAuth operation (authorize/token)
51
+ this.logger.info('OAuth provider initialized', {
52
+ endpoint: this.oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
53
+ hasIssuer: !!config.oauthConfig.issuer,
54
+ });
55
+ }
56
+ else {
57
+ this.logger.info('No OAuth config provided - OAuth provider not initialized');
58
+ }
36
59
  this.app = express();
37
60
  this.setupMiddleware();
38
61
  this.setupRoutes();
@@ -43,11 +66,62 @@ export class HttpTransport {
43
66
  * Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
44
67
  */
45
68
  setupMiddleware() {
69
+ // Request logging (before any middleware)
70
+ this.app.use((req, res, next) => {
71
+ this.logger.debug('Request received', {
72
+ method: req.method,
73
+ url: req.url,
74
+ path: req.path,
75
+ userAgent: req.get('user-agent'),
76
+ ip: req.ip,
77
+ });
78
+ next();
79
+ });
46
80
  // JSON body parser
47
81
  this.app.use(express.json());
48
82
  // Metrics: Track request start time
49
83
  this.app.use((req, res, next) => {
50
84
  req.startTime = Date.now();
85
+ // Log response
86
+ const originalSend = res.send;
87
+ const originalJson = res.json;
88
+ const logger = this.logger;
89
+ res.send = function (body) {
90
+ logger.debug('Outgoing response', {
91
+ method: req.method,
92
+ url: req.url,
93
+ status: res.statusCode,
94
+ contentType: res.get('content-type'),
95
+ bodyLength: body ? body.length : 0,
96
+ bodyPreview: typeof body === 'string' ? body.substring(0, 200) : '[object]'
97
+ });
98
+ return originalSend.call(this, body);
99
+ };
100
+ res.json = function (body) {
101
+ logger.debug('Outgoing JSON response', {
102
+ method: req.method,
103
+ url: req.url,
104
+ status: res.statusCode,
105
+ body
106
+ });
107
+ return originalJson.call(this, body);
108
+ };
109
+ next();
110
+ });
111
+ // Debug: Log all requests
112
+ this.app.use((req, res, next) => {
113
+ this.logger.debug('Incoming request', {
114
+ method: req.method,
115
+ url: req.url,
116
+ path: req.path,
117
+ headers: {
118
+ 'user-agent': req.headers['user-agent'],
119
+ 'accept': req.headers.accept,
120
+ 'content-type': req.headers['content-type'],
121
+ 'authorization': req.headers.authorization ? '[REDACTED]' : undefined
122
+ },
123
+ ip: req.ip
124
+ });
51
125
  next();
52
126
  });
53
127
  // Security: Origin validation (DNS rebinding protection)
@@ -65,7 +139,7 @@ export class HttpTransport {
65
139
  // Validate Origin header for non-localhost
66
140
  if (origin && !this.isAllowedOrigin(origin)) {
67
141
  this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
68
- return res.status(403).json({
142
+ return res.status(HTTP_STATUS.FORBIDDEN).json({
69
143
  error: 'Forbidden',
70
144
  message: 'Origin not allowed'
71
145
  });
@@ -105,6 +179,18 @@ export class HttpTransport {
105
179
  if (hostname === this.config.host) {
106
180
  return true;
107
181
  }
182
+ // Allow OAuth redirect URI host if configured
183
+ if (this.oauthProvider?.redirectUri) {
184
+ try {
185
+ const redirectUrl = new URL(this.oauthProvider.redirectUri);
186
+ if (hostname === redirectUrl.hostname) {
187
+ return true;
188
+ }
189
+ }
190
+ catch {
191
+ // Invalid URL, ignore
192
+ }
193
+ }
108
194
  // Check custom allowed origins
109
195
  if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) {
110
196
  for (const allowed of this.config.allowedOrigins) {
@@ -187,6 +273,39 @@ export class HttpTransport {
187
273
  }
188
274
  return result >>> 0; // Unsigned
189
275
  }
276
+ /**
277
+ * Create configured rate limiter or a passthrough handler when disabled
278
+ *
279
+ * Why: Both MCP and metrics endpoints share the same rate limiting setup logic.
280
+ * Centralizing it keeps behaviour consistent and avoids drifting configuration.
281
+ */
282
+ createRateLimiter(options) {
283
+ if (!options.enabled) {
284
+ return (_req, _res, next) => next();
285
+ }
286
+ const message = options.responseMessage ??
287
+ `Rate limit exceeded. Max ${options.maxRequests} requests per ${options.windowMs / 1000} seconds.`;
288
+ return rateLimit({
289
+ windowMs: options.windowMs,
290
+ max: options.maxRequests,
291
+ standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
292
+ legacyHeaders: false, // Disable deprecated `X-RateLimit-*` headers
293
+ handler: (req, res) => {
294
+ this.logger.warn(options.logMessage, {
295
+ ip: req.ip,
296
+ path: req.path,
297
+ method: req.method,
298
+ });
299
+ res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({
300
+ error: 'Too Many Requests',
301
+ message,
302
+ });
303
+ },
304
+ });
305
+ }
306
+ formatRateLimitMessage(scope, maxRequests, windowMs) {
307
+ return `Rate limit exceeded for ${scope}. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`;
308
+ }
190
309
  /**
191
310
  * Setup MCP endpoint routes
192
311
  *
@@ -194,9 +313,308 @@ export class HttpTransport {
194
313
  */
195
314
  setupRoutes() {
196
315
  this.logger.info('Setting up HTTP routes');
197
- // Security: Rate limiting setup
316
+ // Security: Rate limiting setup (needed for OAuth routes)
198
317
  const rateLimitEnabled = this.config.rateLimitEnabled !== false; // default: true
199
- const windowMs = this.config.rateLimitWindowMs || 60000; // 1 minute
318
+ // Rate limiter for OAuth endpoints (stricter limits for security)
319
+ // OAuth endpoints are sensitive and should have lower limits than general API
320
+ // Configuration priority: profile > env vars > defaults
321
+ const oauthWindowMs = this.config.rateLimitOAuthWindowMs || OAUTH_RATE_LIMIT.WINDOW_MS;
322
+ const oauthMaxRequests = this.config.rateLimitOAuthMax || OAUTH_RATE_LIMIT.MAX_REQUESTS;
323
+ const oauthRateLimiter = this.createRateLimiter({
324
+ enabled: rateLimitEnabled,
325
+ windowMs: oauthWindowMs,
326
+ maxRequests: oauthMaxRequests,
327
+ logMessage: 'Rate limit exceeded for OAuth',
328
+ responseMessage: `Too many OAuth requests. Limit: ${oauthMaxRequests} requests per ${Math.round(oauthWindowMs / 60000)} minutes. Please try again later.`,
329
+ });
330
+ // OAuth 2.0 routes (if configured)
331
+ if (this.oauthProvider) {
332
+ // Build redirect URI
333
+ const redirectUri = this.oauthProvider.redirectUri ||
334
+ `http://${this.config.host}:${this.config.port}/oauth/callback`;
335
+ // Derive serverUrl from redirectUri
336
+ const baseUrl = new URL(redirectUri).origin;
337
+ const serverUrl = new URL(`${baseUrl}/mcp`);
338
+ // issuerUrl should be the base URL of the authorization server (e.g. https://gitlab.com),
339
+ // NOT the authorization endpoint (e.g. https://gitlab.com/oauth/authorize)
340
+ // We try to derive it from authorizationEndpoint if not explicitly configured
341
+ // Note: authorizationEndpoint might not be ready yet (async initialization),
342
+ // so we use serverUrl.origin as fallback
343
+ let issuerUrl;
344
+ try {
345
+ const authEndpoint = this.oauthProvider.authorizationEndpoint;
346
+ if (authEndpoint) {
347
+ // Try to extract base URL from auth endpoint
348
+ const authUrl = new URL(authEndpoint);
349
+ issuerUrl = new URL(authUrl.origin);
350
+ }
351
+ else {
352
+ // Fallback: use server origin (will be updated after async init)
353
+ issuerUrl = new URL(serverUrl.origin);
354
+ }
355
+ }
356
+ catch (e) {
357
+ // Fallback: use server origin
358
+ issuerUrl = new URL(serverUrl.origin);
359
+ }
360
+ this.logger.info('Setting up OAuth routes', {
361
+ serverUrl: serverUrl.toString(),
362
+ issuerUrl: issuerUrl.toString(),
363
+ redirectUri,
364
+ });
365
+ // Install MCP OAuth router
366
+ // This adds standard OAuth endpoints:
367
+ // - /.well-known/oauth-authorization-server
368
+ // - /.well-known/oauth-protected-resource
369
+ // - /oauth/authorize
370
+ // - /oauth/token
371
+ // - /oauth/register (dynamic client registration)
372
+ // - /oauth/revoke (token revocation)
373
+ // Only register resource server endpoints, not authorization server endpoints
374
+ // since our MCP server is not an OAuth authorization server
375
+ this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, oauthRateLimiter, (req, res) => {
376
+ // Build metadata object with only defined fields (RFC 8707)
377
+ const metadata = {
378
+ resource: serverUrl.href,
379
+ authorization_servers: [serverUrl.origin], // We are the authorization server (proxy)
380
+ bearer_methods_supported: ['header'],
381
+ };
382
+ // Optional: scopes_supported (only if scopes are defined)
383
+ if (this.oauthProvider?.scopes && this.oauthProvider.scopes.length > 0) {
384
+ metadata.scopes_supported = this.oauthProvider.scopes;
385
+ }
386
+ // Optional: resource_name (from config, already has fallback in mcp-server.ts)
387
+ if (this.config.resourceName) {
388
+ metadata.resource_name = this.config.resourceName;
389
+ }
390
+ // Optional: resource_documentation (from config, may be undefined)
391
+ if (this.config.resourceDocumentation) {
392
+ metadata.resource_documentation = this.config.resourceDocumentation;
393
+ }
394
+ res.json(metadata);
395
+ });
396
+ // Authorization endpoint
397
+ // Initiates the OAuth flow by redirecting the user to the external provider
398
+ this.app.get(OAUTH_PATHS.AUTHORIZE, oauthRateLimiter, async (req, res) => {
399
+ try {
400
+ const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
401
+ if (!client_id || typeof client_id !== 'string') {
402
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
403
+ return;
404
+ }
405
+ if (!redirect_uri || typeof redirect_uri !== 'string') {
406
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
407
+ return;
408
+ }
409
+ if (this.oauthProvider) {
410
+ // Ensure provider is initialized before client validation
411
+ // This registers configured client_id if present
412
+ await this.oauthProvider.ensureEndpointsInitialized();
413
+ // Find the client to validate configuration
414
+ const client = await this.oauthProvider.clientsStore.getClient(client_id);
415
+ if (!client) {
416
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
417
+ return;
418
+ }
419
+ // Prepare parameters for provider authorization
420
+ const scopeStr = (scope || '').trim();
421
+ const params = {
422
+ responseType: response_type || 'code',
423
+ clientId: client_id,
424
+ redirectUri: redirect_uri,
425
+ scope: scopeStr ? scopeStr.split(' ') : [],
426
+ state: state,
427
+ codeChallenge: code_challenge,
428
+ codeChallengeMethod: code_challenge_method,
429
+ scopes: scopeStr ? scopeStr.split(' ') : [],
430
+ };
431
+ // Call provider authorize method which handles the redirect logic
432
+ await this.oauthProvider.authorize(client, params, res);
433
+ }
434
+ else {
435
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth provider not initialized');
436
+ }
437
+ }
438
+ catch (error) {
439
+ this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
440
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
441
+ }
442
+ });
443
+ // Token endpoint
444
+ // Exchanges authorization code or refresh token for access token
445
+ this.app.post(OAUTH_PATHS.TOKEN, oauthRateLimiter, express.urlencoded({ extended: false }), async (req, res) => {
446
+ try {
447
+ const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
448
+ this.logger.debug('OAuth token request', {
449
+ grant_type,
450
+ client_id,
451
+ has_code: !!code,
452
+ has_code_verifier: !!code_verifier,
453
+ redirect_uri,
454
+ });
455
+ if (grant_type === 'authorization_code') {
456
+ // Authorization Code Flow
457
+ if (!code) {
458
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
459
+ return;
460
+ }
461
+ if (this.oauthProvider) {
462
+ // Ensure provider is initialized before client validation
463
+ await this.oauthProvider.ensureEndpointsInitialized();
464
+ const client = await this.oauthProvider.clientsStore.getClient(client_id);
465
+ if (!client) {
466
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
467
+ return;
468
+ }
469
+ const tokens = await this.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
470
+ // Store OAuth tokens for later session initialization
471
+ this.storeOAuthTokens(tokens, client.client_id, client.scope?.split(' ') || []);
472
+ res.json(tokens);
473
+ }
474
+ else {
475
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
476
+ }
477
+ }
478
+ else if (grant_type === 'refresh_token') {
479
+ // Refresh Token Flow
480
+ if (!refresh_token) {
481
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
482
+ return;
483
+ }
484
+ if (this.oauthProvider) {
485
+ // Ensure provider is initialized before client validation
486
+ await this.oauthProvider.ensureEndpointsInitialized();
487
+ const client = await this.oauthProvider.clientsStore.getClient(client_id);
488
+ if (!client) {
489
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
490
+ return;
491
+ }
492
+ const tokens = await this.oauthProvider.exchangeRefreshToken(client, refresh_token);
493
+ // Store OAuth tokens for later session initialization
494
+ // Note: When refreshing, the old access token should be invalidated
495
+ // but we don't track it here - the new token replaces it in the map
496
+ this.storeOAuthTokens(tokens, client.client_id, client.scope?.split(' ') || []);
497
+ res.json(tokens);
498
+ }
499
+ else {
500
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
501
+ }
502
+ }
503
+ else {
504
+ this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
505
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
506
+ return;
507
+ }
508
+ }
509
+ catch (error) {
510
+ this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
511
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_grant', error_description: String(error) });
512
+ }
513
+ });
514
+ // OAuth callback endpoint to receive tokens from authorization server
515
+ this.app.get(OAUTH_PATHS.CALLBACK, oauthRateLimiter, async (req, res) => {
516
+ try {
517
+ const { code, state, error, error_description } = req.query;
518
+ this.logger.info('OAuth callback received', {
519
+ hasCode: !!code,
520
+ hasState: !!state,
521
+ error: error,
522
+ errorDescription: error_description
523
+ });
524
+ if (error) {
525
+ // Sanitize error messages to prevent XSS
526
+ const safeError = escapeHtmlSafe(error);
527
+ const safeErrorDesc = escapeHtmlSafe(error_description);
528
+ res.status(HTTP_STATUS.BAD_REQUEST).json({
529
+ error: safeError,
530
+ error_description: safeErrorDesc || safeError
531
+ });
532
+ return;
533
+ }
534
+ if (!code || typeof code !== 'string') {
535
+ res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
536
+ return;
537
+ }
538
+ // Delegate to OAuth provider to handle token exchange and redirect back to client (Cursor)
539
+ if (this.oauthProvider) {
540
+ await this.oauthProvider.handleCallback(req, res);
541
+ }
542
+ else {
543
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth provider not initialized');
544
+ }
545
+ }
546
+ catch (error) {
547
+ this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
548
+ if (!res.headersSent) {
549
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
550
+ }
551
+ }
552
+ });
553
+ // Provide authorization server metadata
554
+ // We advertise the MCP server itself as the authorization server (Proxy Mode)
555
+ // This allows us to handle the redirect dance between Cursor -> MCP -> GitLab -> MCP -> Cursor
556
+ this.app.get(OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER, async (req, res) => {
557
+ try {
558
+ res.json({
559
+ issuer: serverUrl.origin, // We are the issuer for the client
560
+ authorization_endpoint: new URL(OAUTH_PATHS.AUTHORIZE, serverUrl.origin).href,
561
+ token_endpoint: new URL(OAUTH_PATHS.TOKEN, serverUrl.origin).href,
562
+ registration_endpoint: new URL(OAUTH_PATHS.REGISTER, serverUrl.origin).href,
563
+ response_types_supported: ['code'],
564
+ code_challenge_methods_supported: ['S256'],
565
+ grant_types_supported: ['authorization_code', 'refresh_token'],
566
+ scopes_supported: this.oauthProvider?.scopes || ['api'],
567
+ });
568
+ }
569
+ catch (error) {
570
+ this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
571
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
572
+ }
573
+ });
574
+ // Dynamic Client Registration endpoint
575
+ // Cursor requires this to register itself with a redirect URI
576
+ this.app.post(OAUTH_PATHS.REGISTER, express.json(), async (req, res) => {
577
+ try {
578
+ const { redirect_uris } = req.body;
579
+ this.logger.info('Dynamic client registration request', { redirect_uris });
580
+ // We don't actually strictly enforce registration in this proxy mode,
581
+ // but we return a valid client configuration to satisfy the client.
582
+ // We use a static client ID for the internal mapping.
583
+ const clientId = 'mcp-proxy-client';
584
+ const clientSecret = 'mcp-proxy-secret';
585
+ // Register this client in our internal store so authorize requests pass validation
586
+ if (this.oauthProvider) {
587
+ const client = {
588
+ client_id: clientId,
589
+ client_secret: clientSecret,
590
+ redirect_uris: redirect_uris || [],
591
+ grant_types: ['authorization_code', 'refresh_token'],
592
+ response_types: ['code'],
593
+ scope: (this.oauthProvider.scopes || []).join(' '),
594
+ };
595
+ // We need to cast to any because registerClient might not be exposed on the interface
596
+ // but we know ExternalOAuthProvider uses InMemoryClientsStore
597
+ await this.oauthProvider.clientsStore.registerClient(client);
598
+ }
599
+ res.status(HTTP_STATUS.CREATED).json({
600
+ client_id: clientId,
601
+ client_secret: clientSecret,
602
+ redirect_uris: redirect_uris,
603
+ grant_types: ['authorization_code', 'refresh_token'],
604
+ response_types: ['code'],
605
+ scope: (this.oauthProvider?.scopes || []).join(' '),
606
+ token_endpoint_auth_method: 'client_secret_post'
607
+ });
608
+ }
609
+ catch (error) {
610
+ this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
611
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
612
+ }
613
+ });
614
+ this.logger.info('OAuth routes registered');
615
+ }
616
+ // Security: Rate limiting setup (for MCP endpoints)
617
+ const windowMs = this.config.rateLimitWindowMs || TIMEOUTS.RATE_LIMIT_WINDOW_MS;
200
618
  const maxRequests = this.config.rateLimitMaxRequests || 100; // 100 req/min
201
619
  const metricsMaxRequests = this.config.rateLimitMetricsMax || 10; // 10 req/min for metrics
202
620
  if (rateLimitEnabled) {
@@ -207,42 +625,30 @@ export class HttpTransport {
207
625
  });
208
626
  }
209
627
  // Rate limiter for MCP/SSE endpoints (100 req/min by default)
210
- const mcpRateLimiter = rateLimitEnabled ? rateLimit({
628
+ const mcpRateLimiter = this.createRateLimiter({
629
+ enabled: rateLimitEnabled,
211
630
  windowMs,
212
- max: maxRequests,
213
- standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
214
- legacyHeaders: false, // Disable `X-RateLimit-*` headers
215
- handler: (req, res) => {
216
- this.logger.warn('Rate limit exceeded', {
217
- ip: req.ip,
218
- path: req.path,
219
- method: req.method,
220
- });
221
- res.status(429).json({
222
- error: 'Too Many Requests',
223
- message: `Rate limit exceeded. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`,
224
- });
225
- },
226
- }) : (req, res, next) => next();
631
+ maxRequests,
632
+ logMessage: 'Rate limit exceeded',
633
+ });
227
634
  // Rate limiter for metrics endpoint (10 req/min by default)
228
- const metricsRateLimiter = rateLimitEnabled ? rateLimit({
635
+ const metricsRateLimiter = this.createRateLimiter({
636
+ enabled: rateLimitEnabled,
229
637
  windowMs,
230
- max: metricsMaxRequests,
231
- standardHeaders: true,
232
- legacyHeaders: false,
233
- handler: (req, res) => {
234
- this.logger.warn('Rate limit exceeded for metrics', {
235
- ip: req.ip,
236
- path: req.path,
237
- });
238
- res.status(429).json({
239
- error: 'Too Many Requests',
240
- message: `Rate limit exceeded for metrics. Max ${metricsMaxRequests} requests per ${windowMs / 1000} seconds.`,
241
- });
242
- },
243
- }) : (req, res, next) => next();
638
+ maxRequests: metricsMaxRequests,
639
+ logMessage: 'Rate limit exceeded for metrics',
640
+ responseMessage: this.formatRateLimitMessage('metrics', metricsMaxRequests, windowMs),
641
+ });
244
642
  // Main MCP endpoint - POST for sending messages
245
643
  this.app.post('/mcp', mcpRateLimiter, this.handlePost.bind(this));
644
+ // Add OPTIONS handler for CORS preflight requests
645
+ this.app.options('/mcp', (req, res) => {
646
+ res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
647
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
648
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id');
649
+ res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
650
+ res.status(HTTP_STATUS.OK).send();
651
+ });
246
652
  this.logger.info('Registered POST /mcp route');
247
653
  // Main MCP endpoint - GET for SSE streaming
248
654
  this.app.get('/mcp', mcpRateLimiter, this.handleGet.bind(this));
@@ -281,10 +687,20 @@ export class HttpTransport {
281
687
  });
282
688
  // Debug: SSE route registered
283
689
  this.logger.info('SSE routes registered successfully');
284
- // Default 404 handler
690
+ // Default 404 handler - MUST be last route registered
691
+ // This will catch all unmatched requests
285
692
  this.app.use((req, res) => {
286
- console.log('=== Default 404 handler called for:', req.method, req.path);
287
- res.status(404).send('<!DOCTYPE html>\n<html>\n<head><title>Error</title></head>\n<body><pre>Cannot ${req.method} ${req.path}</pre></body>\n</html>');
693
+ this.logger.warn('Unhandled request (404)', {
694
+ method: req.method,
695
+ url: req.url,
696
+ path: req.path,
697
+ headers: req.headers,
698
+ ip: req.ip
699
+ });
700
+ res.status(HTTP_STATUS.NOT_FOUND).json({
701
+ error: 'Not Found',
702
+ message: `Endpoint ${req.method} ${req.path} not found`
703
+ });
288
704
  });
289
705
  }
290
706
  /**
@@ -296,7 +712,7 @@ export class HttpTransport {
296
712
  const startTime = Date.now();
297
713
  try {
298
714
  if (!this.metrics) {
299
- res.status(404).json({ error: 'Not Found', message: 'Metrics disabled' });
715
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Metrics disabled' });
300
716
  return;
301
717
  }
302
718
  const metrics = await this.metrics.getMetrics();
@@ -306,7 +722,86 @@ export class HttpTransport {
306
722
  }
307
723
  catch (error) {
308
724
  this.logger.error('Metrics endpoint error', error);
309
- res.status(500).json({ error: 'Internal Server Error', message: error.message });
725
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error', message: error.message });
726
+ }
727
+ }
728
+ /**
729
+ * Validate authentication token by making a probe request to the API
730
+ *
731
+ * Supports all auth types: bearer, query, custom-header
732
+ * Returns true if token is valid, false otherwise
733
+ */
734
+ /**
735
+ * Builds a URL by intelligently combining base URL and endpoint
736
+ * Handles absolute URLs, absolute paths, and relative paths correctly
737
+ */
738
+ buildUrl(endpoint, baseUrl) {
739
+ // If endpoint is already an absolute URL, use it as-is
740
+ if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
741
+ return new URL(endpoint);
742
+ }
743
+ // If endpoint is an absolute path (starts with /), combine with origin of baseUrl
744
+ if (endpoint.startsWith('/')) {
745
+ const baseUrlObj = new URL(baseUrl);
746
+ return new URL(endpoint, baseUrlObj.origin);
747
+ }
748
+ // Otherwise, treat as relative path and append to baseUrl
749
+ // Ensure baseUrl ends with '/' for proper URL construction
750
+ const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
751
+ return new URL(endpoint, normalizedBaseUrl);
752
+ }
753
+ async validateAuthToken(authConfig, token, baseUrl) {
754
+ if (!authConfig.validation_endpoint) {
755
+ return true; // Skip validation if not configured
756
+ }
757
+ const url = this.buildUrl(authConfig.validation_endpoint, baseUrl);
758
+ const headers = {};
759
+ const method = authConfig.validation_method || 'GET';
760
+ const timeout = authConfig.validation_timeout_ms || 5000;
761
+ const urlString = url.toString();
762
+ // Apply auth based on type
763
+ switch (authConfig.type) {
764
+ case 'oauth':
765
+ case 'bearer':
766
+ headers['Authorization'] = `Bearer ${token}`;
767
+ break;
768
+ case 'custom-header':
769
+ if (authConfig.header_name) {
770
+ headers[authConfig.header_name] = token;
771
+ }
772
+ break;
773
+ case 'query':
774
+ if (authConfig.query_param) {
775
+ url.searchParams.set(authConfig.query_param, token);
776
+ }
777
+ break;
778
+ }
779
+ try {
780
+ this.logger.debug('Validating auth token', {
781
+ endpoint: urlString,
782
+ method,
783
+ authType: authConfig.type,
784
+ });
785
+ const controller = new AbortController();
786
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
787
+ const response = await fetch(url.toString(), {
788
+ method,
789
+ headers,
790
+ signal: controller.signal,
791
+ });
792
+ clearTimeout(timeoutId);
793
+ const isValid = response.status >= 200 && response.status < 300;
794
+ this.logger.debug('Auth token validation result', {
795
+ status: response.status,
796
+ isValid,
797
+ });
798
+ return isValid;
799
+ }
800
+ catch (error) {
801
+ this.logger.warn(`Auth token validation failed: ${error.message}`, {
802
+ endpoint: authConfig.validation_endpoint,
803
+ });
804
+ return false;
310
805
  }
311
806
  }
312
807
  /**
@@ -338,10 +833,22 @@ export class HttpTransport {
338
833
  * Supports:
339
834
  * - Authorization: Bearer <token>
340
835
  * - X-API-Token: <token>
836
+ * - OAuth session (via mcp-session-id header)
341
837
  *
342
838
  * Why strict validation: Prevents header injection attacks
839
+ *
840
+ * Returns: { type: 'bearer' | 'oauth' | 'api-token', token: string, sessionId?: string }
343
841
  */
344
842
  extractAuthToken(req) {
843
+ // 1. Check for OAuth session first (highest priority for authenticated sessions)
844
+ const sessionId = req.sessionId || req.headers['mcp-session-id'];
845
+ if (sessionId && this.sessions.has(sessionId)) {
846
+ const session = this.sessions.get(sessionId);
847
+ if (session && session.authToken) {
848
+ return { type: 'oauth', token: session.authToken, sessionId };
849
+ }
850
+ }
851
+ // 2. Check Authorization: Bearer header
345
852
  const authHeader = req.headers.authorization;
346
853
  if (authHeader) {
347
854
  // Relaxed Bearer token format validation - allow flexible whitespace
@@ -353,17 +860,18 @@ export class HttpTransport {
353
860
  }
354
861
  const token = match[1].trim();
355
862
  this.validateToken(token, 'Authorization token');
356
- return token;
863
+ return { type: 'bearer', token };
357
864
  }
865
+ // 3. Check X-API-Token header (for custom implementations)
358
866
  const apiTokenHeader = req.headers['x-api-token'];
359
867
  if (apiTokenHeader) {
360
868
  if (typeof apiTokenHeader !== 'string') {
361
869
  throw new Error('X-API-Token must be a string');
362
870
  }
363
871
  this.validateToken(apiTokenHeader, 'X-API-Token');
364
- return apiTokenHeader;
872
+ return { type: 'api-token', token: apiTokenHeader };
365
873
  }
366
- return undefined;
874
+ return { type: 'none' };
367
875
  }
368
876
  /**
369
877
  * Handle POST requests - Client sending messages to server
@@ -373,68 +881,171 @@ export class HttpTransport {
373
881
  async handlePost(req, res) {
374
882
  const startTime = Date.now();
375
883
  try {
884
+ this.logger.debug('handlePost called', { method: req.method, path: req.path, sessionId: req.sessionId, accept: req.headers.accept });
376
885
  const sessionId = req.sessionId;
377
886
  const body = req.body;
378
- // Validate Accept header
887
+ // Validate Accept header per MCP Streamable HTTP specification
379
888
  const accept = req.headers.accept || '';
380
- if (!accept.includes('application/json') && !accept.includes('text/event-stream')) {
381
- res.status(406).json({ error: 'Not Acceptable', message: 'Must accept application/json or text/event-stream' });
889
+ // POST requests can return either JSON or SSE, so must accept both if specified
890
+ // GET requests return SSE, so must accept text/event-stream
891
+ const acceptsJson = accept.includes(MIME_TYPES.JSON) || accept === '*/*' || accept === '';
892
+ const acceptsEventStream = accept.includes(MIME_TYPES.EVENT_STREAM) || accept === '*/*' || accept === '';
893
+ if (req.method === 'GET' && accept && !acceptsEventStream) {
894
+ this.logger.debug('Accept header validation failed for GET, returning 406');
895
+ res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
896
+ error: 'Not Acceptable',
897
+ message: `GET requests must accept ${MIME_TYPES.EVENT_STREAM}`
898
+ });
899
+ return;
900
+ }
901
+ // For POST, be more flexible - allow if client accepts either JSON or SSE
902
+ if (req.method === 'POST' && accept && !acceptsJson && !acceptsEventStream) {
903
+ this.logger.debug('Accept header validation failed for POST, returning 406');
904
+ res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
905
+ error: 'Not Acceptable',
906
+ message: `POST requests must accept ${MIME_TYPES.JSON} or ${MIME_TYPES.EVENT_STREAM}`
907
+ });
382
908
  return;
383
909
  }
384
910
  // Check if this is initialization (no session ID yet)
385
911
  const isInitialization = isInitializeRequest(body);
912
+ this.logger.debug('Session validation', { isInitialization, sessionId, bodyMethod: body?.method });
386
913
  // Validate session (except for initialization)
387
914
  if (!isInitialization && sessionId) {
388
915
  const session = this.sessions.get(sessionId);
389
916
  if (!session) {
390
- res.status(404).json({ error: 'Not Found', message: 'Session not found or expired' });
917
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
391
918
  return;
392
919
  }
393
920
  this.updateSessionActivity(sessionId);
394
921
  }
395
922
  else if (!isInitialization && !sessionId) {
396
- res.status(400).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required (except for initialization)' });
923
+ this.logger.debug('Session validation failed: non-init request without sessionId');
924
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required (except for initialization)' });
397
925
  return;
398
926
  }
399
927
  // Determine message type
400
928
  const messageType = this.getMessageType(body);
929
+ this.logger.debug('Message type determined', { messageType, hasMessageHandler: !!this.messageHandler });
401
930
  // If only notifications/responses, return 202 Accepted
402
931
  if (messageType === 'notification-only' || messageType === 'response-only') {
403
932
  if (this.messageHandler) {
404
933
  await this.messageHandler(body);
405
934
  }
406
- res.status(202).send();
935
+ res.status(HTTP_STATUS.ACCEPTED).send();
407
936
  return;
408
937
  }
409
938
  // If contains requests, process and return response
410
939
  if (messageType === 'request') {
411
940
  if (!this.messageHandler) {
412
- res.status(500).json({ error: 'Internal Server Error', message: 'Message handler not configured' });
941
+ res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error', message: 'Message handler not configured' });
413
942
  return;
414
943
  }
415
944
  // Create session on initialization
416
945
  let newSessionId;
417
946
  if (isInitialization) {
418
947
  // Extract and validate auth token from headers
419
- const authToken = this.extractAuthToken(req);
420
- newSessionId = this.createSession(authToken);
948
+ const authInfo = this.extractAuthToken(req);
949
+ this.logger.debug('Auth token extracted', { authType: authInfo?.type, hasToken: !!authInfo?.token });
950
+ // If OAuth is configured, require authentication for initialization
951
+ // This ensures clients like Cursor properly handle OAuth flow
952
+ if (this.oauthProvider && !authInfo.token) {
953
+ this.logger.debug('OAuth configured but no token provided, triggering OAuth flow');
954
+ const resourceMetadataUrl = new URL(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, this.getServerUrl()).href;
955
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${this.oauthProvider.scopes.join(' ')}"`);
956
+ res.status(HTTP_STATUS.UNAUTHORIZED).json({
957
+ error: 'Unauthorized',
958
+ message: 'Authentication required for OAuth'
959
+ });
960
+ return;
961
+ }
962
+ // Allow initialization without token for non-OAuth scenarios
963
+ // Validate token if auth is configured and token is provided
964
+ if (authInfo && authInfo.token && this.config.authConfigs && this.config.baseUrl) {
965
+ // Find matching auth config based on priority (authConfigs is sorted)
966
+ // For 'bearer' token type, 'oauth' config is also a match
967
+ const authConfig = this.config.authConfigs.find(c => c.type === authInfo.type ||
968
+ (authInfo.type === 'bearer' && c.type === 'oauth'));
969
+ if (authConfig && authConfig.validation_endpoint) {
970
+ this.logger.info('Validating auth token during initialization', {
971
+ authType: authConfig.type, // Use config type for logging
972
+ endpoint: authConfig.validation_endpoint,
973
+ });
974
+ const isValid = await this.validateAuthToken(authConfig, authInfo.token, this.config.baseUrl);
975
+ if (!isValid) {
976
+ this.logger.warn('Auth token validation failed during initialization', {
977
+ authType: authInfo.type,
978
+ });
979
+ res.status(HTTP_STATUS.UNAUTHORIZED).json({
980
+ error: 'Unauthorized',
981
+ message: 'Invalid or expired authentication token'
982
+ });
983
+ return;
984
+ }
985
+ this.logger.info('Auth token validation successful');
986
+ }
987
+ }
988
+ // Look up OAuth tokens if this is an OAuth token
989
+ let refreshToken;
990
+ let accessTokenExpiresAt;
991
+ let scopes;
992
+ let oauthClientId;
993
+ if (authInfo.token && (authInfo.type === 'oauth' || authInfo.type === 'bearer')) {
994
+ const tokenData = this.oauthTokensByAccessToken.get(authInfo.token);
995
+ if (tokenData) {
996
+ refreshToken = tokenData.refreshToken;
997
+ accessTokenExpiresAt = tokenData.expiresAt;
998
+ scopes = tokenData.scopes;
999
+ oauthClientId = tokenData.clientId;
1000
+ this.logger.debug('Found OAuth token data for session', {
1001
+ hasRefreshToken: !!refreshToken,
1002
+ hasExpiration: !!accessTokenExpiresAt,
1003
+ scopesCount: scopes.length,
1004
+ });
1005
+ }
1006
+ else {
1007
+ this.logger.debug('No OAuth token data found in map (may be non-OAuth bearer token)', {
1008
+ tokenPrefix: authInfo.token.substring(0, 10),
1009
+ });
1010
+ }
1011
+ }
1012
+ newSessionId = this.createSession(authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId);
421
1013
  }
1014
+ this.logger.debug('Calling messageHandler', { body, sessionId: isInitialization ? newSessionId : sessionId });
422
1015
  const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId);
423
- // Check if client prefers SSE stream
424
- if (accept.includes('text/event-stream')) {
425
- // Return SSE stream
1016
+ this.logger.debug('MessageHandler response', { response });
1017
+ // Debug: Check OAuth conditions
1018
+ this.logger.debug('Checking OAuth conditions', {
1019
+ responseError: response.error,
1020
+ hasOAuthProvider: !!this.oauthProvider,
1021
+ oauthProviderType: typeof this.oauthProvider
1022
+ });
1023
+ // Check if response contains OAuth error and add WWW-Authenticate header
1024
+ const responseObj = response;
1025
+ if (responseObj.error && responseObj.error.data && responseObj.error.data.oauth_required) {
1026
+ const resourceMetadataUrl = new URL(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, this.getServerUrl()).href;
1027
+ res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="api"`);
1028
+ res.status(HTTP_STATUS.UNAUTHORIZED); // Set 401 status for OAuth errors
1029
+ }
1030
+ // Decide response format based on Accept header
1031
+ const accept = req.headers.accept || '';
1032
+ const wantsOnlySSE = accept.trim() === MIME_TYPES.EVENT_STREAM;
1033
+ if (wantsOnlySSE) {
1034
+ // Return SSE response only when client explicitly wants text/event-stream only
1035
+ this.logger.debug('Sending SSE response', { response, newSessionId });
426
1036
  this.startSSEResponse(res, response, newSessionId, sessionId);
427
1037
  }
428
1038
  else {
429
- // Return JSON
1039
+ // Return JSON response (default for requests)
430
1040
  if (newSessionId) {
431
1041
  res.setHeader('Mcp-Session-Id', newSessionId);
432
1042
  }
1043
+ this.logger.debug('Sending JSON response', { response, newSessionId });
433
1044
  res.json(response);
434
1045
  }
435
1046
  return;
436
1047
  }
437
- res.status(400).json({ error: 'Bad Request', message: 'Invalid message type' });
1048
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Invalid message type' });
438
1049
  }
439
1050
  catch (error) {
440
1051
  this.logger.error('POST request error', error);
@@ -466,18 +1077,18 @@ export class HttpTransport {
466
1077
  const lastEventId = req.headers['last-event-id'];
467
1078
  // Validate Accept header
468
1079
  const accept = req.headers.accept || '';
469
- if (!accept.includes('text/event-stream')) {
470
- res.status(405).json({ error: 'Method Not Allowed', message: 'Must accept text/event-stream' });
1080
+ if (!accept.includes(MIME_TYPES.EVENT_STREAM)) {
1081
+ res.status(HTTP_STATUS.METHOD_NOT_ALLOWED).json({ error: 'Method Not Allowed', message: `Must accept ${MIME_TYPES.EVENT_STREAM}` });
471
1082
  return;
472
1083
  }
473
1084
  // Validate session
474
1085
  if (!sessionId) {
475
- res.status(400).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
1086
+ res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
476
1087
  return;
477
1088
  }
478
1089
  const session = this.sessions.get(sessionId);
479
1090
  if (!session) {
480
- res.status(404).json({ error: 'Not Found', message: 'Session not found or expired' });
1091
+ res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
481
1092
  return;
482
1093
  }
483
1094
  this.updateSessionActivity(sessionId);
@@ -543,7 +1154,7 @@ export class HttpTransport {
543
1154
  * Why: Returns response via SSE stream, allows server-initiated messages
544
1155
  */
545
1156
  startSSEResponse(res, response, newSessionId, sessionId) {
546
- res.setHeader('Content-Type', 'text/event-stream');
1157
+ res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
547
1158
  res.setHeader('Cache-Control', 'no-cache');
548
1159
  res.setHeader('Connection', 'keep-alive');
549
1160
  if (newSessionId) {
@@ -562,7 +1173,7 @@ export class HttpTransport {
562
1173
  * Why: Allows server to send requests/notifications to client
563
1174
  */
564
1175
  startSSEStream(res, sessionId, lastEventId) {
565
- res.setHeader('Content-Type', 'text/event-stream');
1176
+ res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
566
1177
  res.setHeader('Cache-Control', 'no-cache');
567
1178
  res.setHeader('Connection', 'keep-alive');
568
1179
  const streamId = crypto.randomBytes(16).toString('hex');
@@ -673,7 +1284,7 @@ export class HttpTransport {
673
1284
  *
674
1285
  * Why: Stateful sessions for MCP protocol
675
1286
  */
676
- createSession(authToken) {
1287
+ createSession(authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId) {
677
1288
  // Validate token if provided (defense in depth)
678
1289
  if (authToken) {
679
1290
  this.validateToken(authToken, 'Session auth token');
@@ -685,9 +1296,18 @@ export class HttpTransport {
685
1296
  lastActivityAt: Date.now(),
686
1297
  sseStreams: new Map(),
687
1298
  authToken,
1299
+ refreshToken,
1300
+ accessTokenExpiresAt,
1301
+ scopes,
1302
+ oauthClientId,
688
1303
  };
689
1304
  this.sessions.set(sessionId, session);
690
- this.logger.info('Session created', { sessionId, hasAuthToken: !!authToken });
1305
+ this.logger.info('Session created', {
1306
+ sessionId,
1307
+ hasAuthToken: !!authToken,
1308
+ hasRefreshToken: !!refreshToken,
1309
+ hasExpiration: !!accessTokenExpiresAt,
1310
+ });
691
1311
  // Record metrics
692
1312
  if (this.metrics) {
693
1313
  this.metrics.recordSessionCreated();
@@ -726,6 +1346,10 @@ export class HttpTransport {
726
1346
  }
727
1347
  }
728
1348
  session.sseStreams.clear();
1349
+ // Clean up OAuth token from map if present
1350
+ if (session.authToken) {
1351
+ this.oauthTokensByAccessToken.delete(session.authToken);
1352
+ }
729
1353
  this.sessions.delete(sessionId);
730
1354
  this.logger.info('Session destroyed', { sessionId });
731
1355
  // Notify session destruction listeners (for cleanup in MCPServer)
@@ -761,18 +1385,62 @@ export class HttpTransport {
761
1385
  }
762
1386
  }
763
1387
  }
1388
+ /**
1389
+ * Store OAuth tokens in internal map for later session initialization
1390
+ *
1391
+ * Why: Bridge between /oauth/token endpoint (where we see OAuthTokens)
1392
+ * and session initialization (where we only see access token in Authorization header)
1393
+ */
1394
+ storeOAuthTokens(tokens, clientId, scopes) {
1395
+ if (!tokens.access_token) {
1396
+ this.logger.warn('OAuth tokens missing access_token, skipping storage');
1397
+ return;
1398
+ }
1399
+ const expiresAt = tokens.expires_in
1400
+ ? Date.now() + tokens.expires_in * 1000
1401
+ : undefined;
1402
+ this.oauthTokensByAccessToken.set(tokens.access_token, {
1403
+ refreshToken: tokens.refresh_token,
1404
+ expiresAt,
1405
+ clientId,
1406
+ scopes,
1407
+ });
1408
+ this.logger.debug('Stored OAuth tokens', {
1409
+ hasRefreshToken: !!tokens.refresh_token,
1410
+ expiresAt,
1411
+ clientId,
1412
+ scopesCount: scopes.length,
1413
+ });
1414
+ }
764
1415
  /**
765
1416
  * Cleanup expired sessions
766
1417
  *
767
1418
  * Why: Prevent memory leaks, enforce session timeout
1419
+ *
1420
+ * OAuth sessions with refresh tokens have extended or unlimited timeout
1421
+ * to avoid forcing users to re-authenticate after periods of inactivity
768
1422
  */
769
1423
  cleanupExpiredSessions() {
770
1424
  const now = Date.now();
771
1425
  const expiredSessions = [];
1426
+ // Default OAuth session timeout: 24 hours (or configurable)
1427
+ const oauthSessionTimeoutMs = this.config.oauthSessionTimeoutMs
1428
+ ?? (24 * 60 * 60 * 1000); // 24 hours default
772
1429
  for (const [sessionId, session] of this.sessions) {
773
1430
  const age = now - session.lastActivityAt;
774
- if (age > this.config.sessionTimeoutMs) {
775
- expiredSessions.push(sessionId);
1431
+ // OAuth sessions with refresh tokens: use extended timeout or never expire
1432
+ if (session.refreshToken) {
1433
+ // If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
1434
+ if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
1435
+ expiredSessions.push(sessionId);
1436
+ }
1437
+ // Otherwise, keep the session alive (unlimited timeout)
1438
+ }
1439
+ else {
1440
+ // Non-OAuth sessions: use standard timeout
1441
+ if (age > this.config.sessionTimeoutMs) {
1442
+ expiredSessions.push(sessionId);
1443
+ }
776
1444
  }
777
1445
  }
778
1446
  for (const sessionId of expiredSessions) {
@@ -791,30 +1459,206 @@ export class HttpTransport {
791
1459
  const session = this.sessions.get(sessionId);
792
1460
  return session?.authToken;
793
1461
  }
1462
+ /**
1463
+ * Ensure session has a valid access token, refreshing if necessary
1464
+ *
1465
+ * Why: Transparently refresh expired OAuth tokens before making API calls
1466
+ * Returns true if token is valid (or was successfully refreshed), false otherwise
1467
+ */
1468
+ async ensureValidSessionToken(sessionId) {
1469
+ const session = this.sessions.get(sessionId);
1470
+ if (!session) {
1471
+ return false;
1472
+ }
1473
+ // If no expiration info, assume token is valid (non-OAuth scenarios)
1474
+ if (!session.accessTokenExpiresAt) {
1475
+ return true;
1476
+ }
1477
+ const now = Date.now();
1478
+ const refreshThresholdMs = this.config.oauthRefreshThresholdMs ?? (60 * 1000); // Default: 60 seconds before expiration
1479
+ const timeUntilExpiration = session.accessTokenExpiresAt - now;
1480
+ // If token is expired or about to expire, refresh it
1481
+ if (timeUntilExpiration <= refreshThresholdMs) {
1482
+ this.logger.debug('Access token expired or expiring soon, refreshing', {
1483
+ sessionId,
1484
+ expiresAt: new Date(session.accessTokenExpiresAt).toISOString(),
1485
+ timeUntilExpiration,
1486
+ });
1487
+ return await this.refreshAccessToken(sessionId);
1488
+ }
1489
+ return true;
1490
+ }
1491
+ /**
1492
+ * Refresh access token using refresh token
1493
+ *
1494
+ * Why: Automatically renew expired OAuth access tokens without user intervention
1495
+ * Returns true on success, false on failure
1496
+ */
1497
+ async refreshAccessToken(sessionId) {
1498
+ const session = this.sessions.get(sessionId);
1499
+ if (!session || !session.refreshToken || !this.oauthProvider) {
1500
+ this.logger.warn('Cannot refresh token: missing session, refreshToken, or OAuth provider', {
1501
+ sessionId,
1502
+ hasSession: !!session,
1503
+ hasRefreshToken: !!session?.refreshToken,
1504
+ hasOAuthProvider: !!this.oauthProvider,
1505
+ });
1506
+ return false;
1507
+ }
1508
+ try {
1509
+ // Get client from OAuth provider
1510
+ // Try to find client by clientId stored in session, or use default client
1511
+ let client;
1512
+ if (session.oauthClientId) {
1513
+ await this.oauthProvider.ensureEndpointsInitialized();
1514
+ client = await this.oauthProvider.clientsStore.getClient(session.oauthClientId);
1515
+ }
1516
+ // Fallback to default client from config if session client not found
1517
+ if (!client && this.oauthProvider) {
1518
+ await this.oauthProvider.ensureEndpointsInitialized();
1519
+ // Try common client IDs
1520
+ const defaultClientIds = ['mcp-proxy-client'];
1521
+ if (this.config.oauthConfig?.client_id) {
1522
+ defaultClientIds.unshift(this.config.oauthConfig.client_id);
1523
+ }
1524
+ for (const clientId of defaultClientIds) {
1525
+ client = await this.oauthProvider.clientsStore.getClient(clientId);
1526
+ if (client)
1527
+ break;
1528
+ }
1529
+ }
1530
+ if (!client) {
1531
+ this.logger.error('Cannot refresh token: OAuth client not found', undefined, {
1532
+ sessionId,
1533
+ oauthClientId: session.oauthClientId,
1534
+ });
1535
+ return false;
1536
+ }
1537
+ // Exchange refresh token for new tokens
1538
+ const tokens = await this.oauthProvider.exchangeRefreshToken(client, session.refreshToken, session.scopes);
1539
+ // Update session with new tokens
1540
+ const oldAccessToken = session.authToken;
1541
+ session.authToken = tokens.access_token;
1542
+ session.refreshToken = tokens.refresh_token || session.refreshToken; // Keep old refresh token if new one not provided
1543
+ session.accessTokenExpiresAt = tokens.expires_in
1544
+ ? Date.now() + tokens.expires_in * 1000
1545
+ : undefined;
1546
+ // Update token map: remove old token, add new one
1547
+ if (oldAccessToken) {
1548
+ this.oauthTokensByAccessToken.delete(oldAccessToken);
1549
+ }
1550
+ this.storeOAuthTokens(tokens, client.client_id, session.scopes || []);
1551
+ this.logger.info('Access token refreshed successfully', {
1552
+ sessionId,
1553
+ newExpiresAt: session.accessTokenExpiresAt ? new Date(session.accessTokenExpiresAt).toISOString() : undefined,
1554
+ });
1555
+ return true;
1556
+ }
1557
+ catch (error) {
1558
+ this.logger.error('Token refresh failed', error instanceof Error ? error : new Error(String(error)), {
1559
+ sessionId,
1560
+ });
1561
+ return false;
1562
+ }
1563
+ }
794
1564
  /**
795
1565
  * Set message handler for processing incoming JSON-RPC messages
796
1566
  */
797
1567
  setMessageHandler(handler) {
798
1568
  this.messageHandler = handler;
799
1569
  }
1570
+ /**
1571
+ * Check if OAuth provider is configured
1572
+ */
1573
+ hasOAuthProvider() {
1574
+ return this.oauthProvider !== null;
1575
+ }
1576
+ /**
1577
+ * Get server URL
1578
+ */
1579
+ getServerUrl() {
1580
+ // Prefer base URL derived from OAuth redirect URI if available
1581
+ // This ensures consistency with the public address used for OAuth callbacks
1582
+ if (this.oauthProvider?.redirectUri) {
1583
+ try {
1584
+ return new URL(this.oauthProvider.redirectUri).origin;
1585
+ }
1586
+ catch (e) {
1587
+ // Ignore invalid URL format
1588
+ }
1589
+ }
1590
+ // Fallback to configured host/port
1591
+ // If configured with 0.0.0.0, this will return http://0.0.0.0:port
1592
+ // which is usually fine for internal communication but not for external clients
1593
+ const protocol = this.config.host.includes('://') ? '' : 'http://';
1594
+ const host = this.config.host.includes('://') ? this.config.host : this.config.host;
1595
+ return `${protocol}${host}:${this.config.port}`;
1596
+ }
1597
+ /**
1598
+ * Get OAuth authorization URL
1599
+ */
1600
+ getOAuthAuthorizationUrl() {
1601
+ return this.oauthProvider?.authorizationEndpoint || '';
1602
+ }
1603
+ /**
1604
+ * Get OAuth scopes
1605
+ */
1606
+ getOAuthScopes() {
1607
+ return this.oauthProvider?.scopes || [];
1608
+ }
800
1609
  /**
801
1610
  * Start HTTP server
802
1611
  */
803
1612
  async start() {
804
1613
  return new Promise((resolve, reject) => {
805
1614
  try {
806
- this.server = this.app.listen(this.config.port, this.config.host, () => {
807
- this.logger.info('HTTP transport started', {
808
- host: this.config.host,
809
- port: this.config.port,
810
- heartbeat: this.config.heartbeatEnabled,
811
- metrics: this.config.metricsEnabled,
1615
+ // Check for SSL configuration from environment variables
1616
+ const sslCertFile = process.env.MCP4_SSL_CERT_FILE;
1617
+ const sslKeyFile = process.env.MCP4_SSL_KEY_FILE;
1618
+ if (sslCertFile && sslKeyFile) {
1619
+ // Start HTTPS server
1620
+ this.logger.info('SSL configuration detected, starting HTTPS server', {
1621
+ certFile: sslCertFile,
1622
+ keyFile: sslKeyFile,
812
1623
  });
813
- // Start session cleanup interval
814
- this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), 60000 // Check every minute
815
- );
816
- resolve();
817
- });
1624
+ try {
1625
+ const httpsOptions = {
1626
+ cert: fs.readFileSync(sslCertFile),
1627
+ key: fs.readFileSync(sslKeyFile),
1628
+ };
1629
+ this.server = https.createServer(httpsOptions, this.app);
1630
+ this.server.listen(this.config.port, this.config.host, () => {
1631
+ this.logger.info('HTTPS transport started', {
1632
+ host: this.config.host,
1633
+ port: this.config.port,
1634
+ heartbeat: this.config.heartbeatEnabled,
1635
+ metrics: this.config.metricsEnabled,
1636
+ });
1637
+ // Start session cleanup interval
1638
+ this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
1639
+ resolve();
1640
+ });
1641
+ }
1642
+ catch (sslError) {
1643
+ this.logger.error('Failed to start HTTPS server', sslError instanceof Error ? sslError : new Error(String(sslError)));
1644
+ reject(sslError);
1645
+ return;
1646
+ }
1647
+ }
1648
+ else {
1649
+ // Start HTTP server
1650
+ this.server = this.app.listen(this.config.port, this.config.host, () => {
1651
+ this.logger.info('HTTP transport started', {
1652
+ host: this.config.host,
1653
+ port: this.config.port,
1654
+ heartbeat: this.config.heartbeatEnabled,
1655
+ metrics: this.config.metricsEnabled,
1656
+ });
1657
+ // Start session cleanup interval
1658
+ this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
1659
+ resolve();
1660
+ });
1661
+ }
818
1662
  this.server.on('error', reject);
819
1663
  }
820
1664
  catch (error) {