mcp4openapi 0.1.0 → 0.2.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.
- package/README.md +134 -95
- package/dist/scripts/validate-profile.js +3 -3
- package/dist/scripts/validate-profile.js.map +1 -1
- package/dist/src/composite-executor.d.ts +3 -1
- package/dist/src/composite-executor.d.ts.map +1 -1
- package/dist/src/composite-executor.js +16 -5
- package/dist/src/composite-executor.js.map +1 -1
- package/dist/src/constants.d.ts +49 -0
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +49 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/errors.d.ts +6 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +13 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/generated-schemas.d.ts +832 -52
- package/dist/src/generated-schemas.d.ts.map +1 -1
- package/dist/src/generated-schemas.js +31 -8
- package/dist/src/generated-schemas.js.map +1 -1
- package/dist/src/http-client-factory.d.ts.map +1 -1
- package/dist/src/http-client-factory.js +14 -3
- package/dist/src/http-client-factory.js.map +1 -1
- package/dist/src/http-transport.d.ts +65 -0
- package/dist/src/http-transport.d.ts.map +1 -1
- package/dist/src/http-transport.js +921 -77
- package/dist/src/http-transport.js.map +1 -1
- package/dist/src/index.js +108 -8
- package/dist/src/index.js.map +1 -1
- package/dist/src/interceptors.d.ts +3 -0
- package/dist/src/interceptors.d.ts.map +1 -1
- package/dist/src/interceptors.js +50 -8
- package/dist/src/interceptors.js.map +1 -1
- package/dist/src/logger.d.ts +1 -1
- package/dist/src/logger.js +3 -3
- package/dist/src/logger.js.map +1 -1
- package/dist/src/mcp-server.d.ts +33 -0
- package/dist/src/mcp-server.d.ts.map +1 -1
- package/dist/src/mcp-server.js +263 -54
- package/dist/src/mcp-server.js.map +1 -1
- package/dist/src/oauth-provider.d.ts +92 -0
- package/dist/src/oauth-provider.d.ts.map +1 -0
- package/dist/src/oauth-provider.js +588 -0
- package/dist/src/oauth-provider.js.map +1 -0
- package/dist/src/openapi-parser.d.ts +16 -0
- package/dist/src/openapi-parser.d.ts.map +1 -1
- package/dist/src/openapi-parser.js +141 -6
- package/dist/src/openapi-parser.js.map +1 -1
- package/dist/src/profile-loader.d.ts +2 -2
- package/dist/src/profile-loader.d.ts.map +1 -1
- package/dist/src/profile-loader.js +45 -24
- package/dist/src/profile-loader.js.map +1 -1
- package/dist/src/testing/fixtures.d.ts +32 -0
- package/dist/src/testing/fixtures.d.ts.map +1 -1
- package/dist/src/testing/fixtures.js +26 -0
- package/dist/src/testing/fixtures.js.map +1 -1
- package/dist/src/testing/mock-gitlab-server.d.ts.map +1 -1
- package/dist/src/testing/mock-gitlab-server.js +131 -1
- package/dist/src/testing/mock-gitlab-server.js.map +1 -1
- package/dist/src/types/http-transport.d.ts +16 -0
- package/dist/src/types/http-transport.d.ts.map +1 -1
- package/dist/src/types/openapi.d.ts +5 -0
- package/dist/src/types/openapi.d.ts.map +1 -1
- package/dist/src/types/profile.d.ts +112 -3
- package/dist/src/types/profile.d.ts.map +1 -1
- package/dist/src/validation-utils.d.ts +12 -0
- package/dist/src/validation-utils.d.ts.map +1 -1
- package/dist/src/validation-utils.js +17 -0
- package/dist/src/validation-utils.js.map +1 -1
- package/package.json +7 -3
- package/profile-schema.json +169 -7
- package/dist/composite-executor.d.ts +0 -65
- package/dist/composite-executor.d.ts.map +0 -1
- package/dist/composite-executor.js +0 -147
- package/dist/composite-executor.js.map +0 -1
- package/dist/constants.d.ts +0 -36
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -36
- package/dist/constants.js.map +0 -1
- package/dist/http-transport.d.ts +0 -195
- package/dist/http-transport.d.ts.map +0 -1
- package/dist/http-transport.js +0 -760
- package/dist/http-transport.js.map +0 -1
- package/dist/interceptors.d.ts +0 -74
- package/dist/interceptors.d.ts.map +0 -1
- package/dist/interceptors.js +0 -220
- package/dist/interceptors.js.map +0 -1
- package/dist/logger.d.ts +0 -81
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -264
- package/dist/logger.js.map +0 -1
- package/dist/mcp-server.d.ts +0 -110
- package/dist/mcp-server.d.ts.map +0 -1
- package/dist/mcp-server.js +0 -568
- package/dist/mcp-server.js.map +0 -1
- package/dist/metrics.d.ts +0 -86
- package/dist/metrics.d.ts.map +0 -1
- package/dist/metrics.js +0 -229
- package/dist/metrics.js.map +0 -1
- package/dist/openapi-parser.d.ts +0 -35
- package/dist/openapi-parser.d.ts.map +0 -1
- package/dist/openapi-parser.js +0 -160
- package/dist/openapi-parser.js.map +0 -1
- package/dist/profile-loader.d.ts +0 -25
- package/dist/profile-loader.d.ts.map +0 -1
- package/dist/profile-loader.js +0 -134
- package/dist/profile-loader.js.map +0 -1
- package/dist/schema-validator.d.ts +0 -32
- package/dist/schema-validator.d.ts.map +0 -1
- package/dist/schema-validator.js +0 -126
- package/dist/schema-validator.js.map +0 -1
- package/dist/testing/fixtures.d.ts +0 -186
- package/dist/testing/fixtures.d.ts.map +0 -1
- package/dist/testing/fixtures.js +0 -135
- package/dist/testing/fixtures.js.map +0 -1
- package/dist/testing/http-integration.test.d.ts +0 -7
- package/dist/testing/http-integration.test.d.ts.map +0 -1
- package/dist/testing/http-integration.test.js +0 -383
- package/dist/testing/http-integration.test.js.map +0 -1
- package/dist/testing/http-multiuser.test.d.ts +0 -10
- package/dist/testing/http-multiuser.test.d.ts.map +0 -1
- package/dist/testing/http-multiuser.test.js +0 -255
- package/dist/testing/http-multiuser.test.js.map +0 -1
- package/dist/testing/integration.test.d.ts +0 -8
- package/dist/testing/integration.test.d.ts.map +0 -1
- package/dist/testing/integration.test.js +0 -247
- package/dist/testing/integration.test.js.map +0 -1
- package/dist/testing/mock-gitlab-server.d.ts +0 -34
- package/dist/testing/mock-gitlab-server.d.ts.map +0 -1
- package/dist/testing/mock-gitlab-server.js +0 -224
- package/dist/testing/mock-gitlab-server.js.map +0 -1
- package/dist/testing/test-types.d.ts +0 -59
- package/dist/testing/test-types.d.ts.map +0 -1
- package/dist/testing/test-types.js +0 -7
- package/dist/testing/test-types.js.map +0 -1
- package/dist/tool-generator.d.ts +0 -43
- package/dist/tool-generator.d.ts.map +0 -1
- package/dist/tool-generator.js +0 -123
- package/dist/tool-generator.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/dist/types/http-transport.d.ts +0 -39
- package/dist/types/http-transport.d.ts.map +0 -1
- package/dist/types/http-transport.js +0 -8
- package/dist/types/http-transport.js.map +0 -1
- package/dist/types/openapi.d.ts +0 -50
- package/dist/types/openapi.d.ts.map +0 -1
- package/dist/types/openapi.js +0 -9
- package/dist/types/openapi.js.map +0 -1
- package/dist/types/profile.d.ts +0 -76
- package/dist/types/profile.d.ts.map +0 -1
- package/dist/types/profile.js +0 -9
- 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(
|
|
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
|
-
|
|
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 =
|
|
628
|
+
const mcpRateLimiter = this.createRateLimiter({
|
|
629
|
+
enabled: rateLimitEnabled,
|
|
211
630
|
windowMs,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
635
|
+
const metricsRateLimiter = this.createRateLimiter({
|
|
636
|
+
enabled: rateLimitEnabled,
|
|
229
637
|
windowMs,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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(
|
|
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(
|
|
470
|
-
res.status(
|
|
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(
|
|
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(
|
|
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',
|
|
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',
|
|
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', {
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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) {
|