mcp4openapi 0.3.1 → 0.3.3

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 +7 -0
  2. package/dist/src/core/cli-config.d.ts.map +1 -1
  3. package/dist/src/core/cli-config.js +2 -0
  4. package/dist/src/core/cli-config.js.map +1 -1
  5. package/dist/src/core/index.d.ts.map +1 -1
  6. package/dist/src/core/index.js +18 -3
  7. package/dist/src/core/index.js.map +1 -1
  8. package/dist/src/index.js +0 -0
  9. package/dist/src/profile/profile-allowlist.d.ts +18 -0
  10. package/dist/src/profile/profile-allowlist.d.ts.map +1 -0
  11. package/dist/src/profile/profile-allowlist.js +68 -0
  12. package/dist/src/profile/profile-allowlist.js.map +1 -0
  13. package/dist/src/profile/profile-registry.d.ts +5 -0
  14. package/dist/src/profile/profile-registry.d.ts.map +1 -1
  15. package/dist/src/profile/profile-registry.js +38 -14
  16. package/dist/src/profile/profile-registry.js.map +1 -1
  17. package/package.json +2 -2
  18. package/profiles/gitlab/developer-profile-oauth.json +243 -41
  19. package/profiles/gitlab/developer-profile-oauth.test.json +1009 -5
  20. package/profiles/gitlab/openapi.yaml +1419 -164
  21. package/profiles/gitlab/profile-optimized-oauth.json +785 -0
  22. package/profiles/gitlab/profile-optimized-oauth.test.json +1566 -0
  23. package/profiles/grafana/openapi.json +28078 -0
  24. package/profiles/grafana/profile.json +1083 -0
  25. package/profiles/grafana/profile.test.json +235 -0
  26. package/profiles/mattermost/openapi.yaml +27434 -0
  27. package/profiles/mattermost/profile.json +463 -0
  28. package/profiles/mattermost/profile.test.json +607 -0
  29. package/profiles/n8n/profile-optimized.json +1002 -364
  30. package/profiles/n8n/profile-optimized.test.json +43 -43
  31. package/dist/src/argument-normalizer.d.ts +0 -5
  32. package/dist/src/argument-normalizer.d.ts.map +0 -1
  33. package/dist/src/argument-normalizer.js +0 -61
  34. package/dist/src/argument-normalizer.js.map +0 -1
  35. package/dist/src/cli-config.d.ts +0 -9
  36. package/dist/src/cli-config.d.ts.map +0 -1
  37. package/dist/src/cli-config.js +0 -111
  38. package/dist/src/cli-config.js.map +0 -1
  39. package/dist/src/composite-executor.d.ts +0 -77
  40. package/dist/src/composite-executor.d.ts.map +0 -1
  41. package/dist/src/composite-executor.js +0 -193
  42. package/dist/src/composite-executor.js.map +0 -1
  43. package/dist/src/constants.d.ts +0 -85
  44. package/dist/src/constants.d.ts.map +0 -1
  45. package/dist/src/constants.js +0 -85
  46. package/dist/src/constants.js.map +0 -1
  47. package/dist/src/dag-executor.d.ts +0 -49
  48. package/dist/src/dag-executor.d.ts.map +0 -1
  49. package/dist/src/dag-executor.js +0 -138
  50. package/dist/src/dag-executor.js.map +0 -1
  51. package/dist/src/errors.d.ts +0 -59
  52. package/dist/src/errors.d.ts.map +0 -1
  53. package/dist/src/errors.js +0 -119
  54. package/dist/src/errors.js.map +0 -1
  55. package/dist/src/filtering.d.ts +0 -19
  56. package/dist/src/filtering.d.ts.map +0 -1
  57. package/dist/src/filtering.js +0 -292
  58. package/dist/src/filtering.js.map +0 -1
  59. package/dist/src/http-client-factory.d.ts +0 -62
  60. package/dist/src/http-client-factory.d.ts.map +0 -1
  61. package/dist/src/http-client-factory.js +0 -133
  62. package/dist/src/http-client-factory.js.map +0 -1
  63. package/dist/src/http-transport-config.d.ts +0 -6
  64. package/dist/src/http-transport-config.d.ts.map +0 -1
  65. package/dist/src/http-transport-config.js +0 -47
  66. package/dist/src/http-transport-config.js.map +0 -1
  67. package/dist/src/http-transport.d.ts +0 -316
  68. package/dist/src/http-transport.d.ts.map +0 -1
  69. package/dist/src/http-transport.js +0 -2412
  70. package/dist/src/http-transport.js.map +0 -1
  71. package/dist/src/interceptors.d.ts +0 -116
  72. package/dist/src/interceptors.d.ts.map +0 -1
  73. package/dist/src/interceptors.js +0 -392
  74. package/dist/src/interceptors.js.map +0 -1
  75. package/dist/src/jsonrpc-validator.d.ts +0 -27
  76. package/dist/src/jsonrpc-validator.d.ts.map +0 -1
  77. package/dist/src/jsonrpc-validator.js +0 -58
  78. package/dist/src/jsonrpc-validator.js.map +0 -1
  79. package/dist/src/logger.d.ts +0 -59
  80. package/dist/src/logger.d.ts.map +0 -1
  81. package/dist/src/logger.js +0 -177
  82. package/dist/src/logger.js.map +0 -1
  83. package/dist/src/mcp-server-manager.d.ts +0 -20
  84. package/dist/src/mcp-server-manager.d.ts.map +0 -1
  85. package/dist/src/mcp-server-manager.js +0 -38
  86. package/dist/src/mcp-server-manager.js.map +0 -1
  87. package/dist/src/mcp-server.d.ts +0 -203
  88. package/dist/src/mcp-server.d.ts.map +0 -1
  89. package/dist/src/mcp-server.js +0 -1369
  90. package/dist/src/mcp-server.js.map +0 -1
  91. package/dist/src/metrics.d.ts +0 -97
  92. package/dist/src/metrics.d.ts.map +0 -1
  93. package/dist/src/metrics.js +0 -273
  94. package/dist/src/metrics.js.map +0 -1
  95. package/dist/src/naming-warnings.d.ts +0 -23
  96. package/dist/src/naming-warnings.d.ts.map +0 -1
  97. package/dist/src/naming-warnings.js +0 -83
  98. package/dist/src/naming-warnings.js.map +0 -1
  99. package/dist/src/naming.d.ts +0 -58
  100. package/dist/src/naming.d.ts.map +0 -1
  101. package/dist/src/naming.js +0 -510
  102. package/dist/src/naming.js.map +0 -1
  103. package/dist/src/oauth-provider.d.ts +0 -131
  104. package/dist/src/oauth-provider.d.ts.map +0 -1
  105. package/dist/src/oauth-provider.js +0 -836
  106. package/dist/src/oauth-provider.js.map +0 -1
  107. package/dist/src/openapi-parser.d.ts +0 -70
  108. package/dist/src/openapi-parser.d.ts.map +0 -1
  109. package/dist/src/openapi-parser.js +0 -436
  110. package/dist/src/openapi-parser.js.map +0 -1
  111. package/dist/src/profile-loader.d.ts +0 -78
  112. package/dist/src/profile-loader.d.ts.map +0 -1
  113. package/dist/src/profile-loader.js +0 -483
  114. package/dist/src/profile-loader.js.map +0 -1
  115. package/dist/src/profile-registry.d.ts +0 -18
  116. package/dist/src/profile-registry.d.ts.map +0 -1
  117. package/dist/src/profile-registry.js +0 -26
  118. package/dist/src/profile-registry.js.map +0 -1
  119. package/dist/src/profile-resolver.d.ts +0 -19
  120. package/dist/src/profile-resolver.d.ts.map +0 -1
  121. package/dist/src/profile-resolver.js +0 -167
  122. package/dist/src/profile-resolver.js.map +0 -1
  123. package/dist/src/proxy-executor.d.ts +0 -86
  124. package/dist/src/proxy-executor.d.ts.map +0 -1
  125. package/dist/src/proxy-executor.js +0 -497
  126. package/dist/src/proxy-executor.js.map +0 -1
  127. package/dist/src/schema-validator.d.ts +0 -30
  128. package/dist/src/schema-validator.d.ts.map +0 -1
  129. package/dist/src/schema-validator.js +0 -128
  130. package/dist/src/schema-validator.js.map +0 -1
  131. package/dist/src/startup-profile.d.ts +0 -17
  132. package/dist/src/startup-profile.d.ts.map +0 -1
  133. package/dist/src/startup-profile.js +0 -30
  134. package/dist/src/startup-profile.js.map +0 -1
  135. package/dist/src/startup-validation.d.ts +0 -11
  136. package/dist/src/startup-validation.d.ts.map +0 -1
  137. package/dist/src/startup-validation.js +0 -21
  138. package/dist/src/startup-validation.js.map +0 -1
  139. package/dist/src/tool-filter.d.ts +0 -65
  140. package/dist/src/tool-filter.d.ts.map +0 -1
  141. package/dist/src/tool-filter.js +0 -471
  142. package/dist/src/tool-filter.js.map +0 -1
  143. package/dist/src/tool-generator.d.ts +0 -67
  144. package/dist/src/tool-generator.d.ts.map +0 -1
  145. package/dist/src/tool-generator.js +0 -182
  146. package/dist/src/tool-generator.js.map +0 -1
  147. package/dist/src/validation-utils.d.ts +0 -49
  148. package/dist/src/validation-utils.d.ts.map +0 -1
  149. package/dist/src/validation-utils.js +0 -138
  150. package/dist/src/validation-utils.js.map +0 -1
  151. package/profiles/gitlab/developer-profile.json +0 -1508
  152. package/profiles/gitlab/developer-profile.test.json +0 -3432
@@ -1,2412 +0,0 @@
1
- /**
2
- * HTTP Streamable Transport for MCP
3
- *
4
- * Implements MCP Specification 2025-03-26
5
- * https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
6
- *
7
- * Why: Enables remote MCP server access with SSE streaming, session management,
8
- * and resumability for reliable communication over HTTP.
9
- */
10
- import express from 'express';
11
- import https from 'https';
12
- import fs from 'fs';
13
- import crypto from 'crypto';
14
- import { isIP } from 'node:net';
15
- import rateLimit from 'express-rate-limit';
16
- import { isInitializeRequest } from './jsonrpc-validator.js';
17
- import { MetricsCollector } from './metrics.js';
18
- import { ExternalOAuthProvider } from './oauth-provider.js';
19
- import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from './constants.js';
20
- import { escapeHtmlSafe } from './validation-utils.js';
21
- import { AuthenticationError, AuthorizationError, ConfigurationError, RateLimitError, ValidationError, generateCorrelationId, } from './errors.js';
22
- import { parseFilteringHeader, normalizeFilteringHeaderValue } from './filtering.js';
23
- import { ToolFilterService, EnvConfigParser, HeaderConfigParser, RegexCompiler, RegexValidator, OperationClassifier, OpenAPIOperationResolver, OperationDetector, normalizeToolFilterHeaderValue, parseSessionToolFilterHeader, } from './tool-filter/index.js';
24
- const DEFAULT_MAX_TOKEN_LENGTH = 1000;
25
- export class HttpTransport {
26
- constructor(config, logger) {
27
- this.server = null;
28
- this.metrics = null;
29
- this.cleanupInterval = null;
30
- this.messageHandler = null;
31
- this.profileContextProvider = null;
32
- this.profileStates = new Map();
33
- this.oauthRedirectHostCache = new Map();
34
- this.warnedMissingOAuthRedirectEnvVars = new Set();
35
- this.hasWarnedAboutBinding = false;
36
- /**
37
- * Session destruction listeners for cleanup in other components
38
- */
39
- this.sessionDestroyedListeners = [];
40
- // Freeze config to prevent runtime mutation of security-critical settings (allowedOrigins, rate limits, etc.)
41
- this.config = Object.freeze({ ...config });
42
- this.logger = logger;
43
- // Initialize metrics if enabled
44
- if (config.metricsEnabled) {
45
- this.metrics = new MetricsCollector({
46
- enabled: true,
47
- prefix: 'mcp_',
48
- });
49
- }
50
- this.app = express();
51
- this.setupMiddleware();
52
- this.setupRoutes();
53
- }
54
- /**
55
- * Setup Express middleware
56
- *
57
- * Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
58
- */
59
- setupMiddleware() {
60
- // Security: standard headers
61
- this.app.disable('x-powered-by');
62
- this.app.use((req, res, next) => {
63
- res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'");
64
- res.setHeader('X-Frame-Options', 'DENY');
65
- res.setHeader('X-Content-Type-Options', 'nosniff');
66
- res.setHeader('Referrer-Policy', 'no-referrer');
67
- res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
68
- // Additional security headers
69
- res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
70
- res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
71
- res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
72
- res.setHeader('X-DNS-Prefetch-Control', 'off');
73
- next();
74
- });
75
- // Request logging (before any middleware)
76
- this.app.use((req, res, next) => {
77
- this.logger.debug('Request received', {
78
- method: req.method,
79
- url: req.url,
80
- path: req.path,
81
- userAgent: req.get('user-agent'),
82
- ip: req.ip,
83
- });
84
- next();
85
- });
86
- // DNS rebinding protection when binding to localhost
87
- // Deny requests with mismatched Host headers to prevent DNS rebinding attacks
88
- // Applies when server host is localhost/127.0.0.1, regardless of auth configuration
89
- this.app.use((req, res, next) => {
90
- const hostCfg = this.config.host?.toLowerCase();
91
- if (hostCfg === 'localhost' || hostCfg === '127.0.0.1') {
92
- const hostHeader = (req.headers['host'] || '').toString().toLowerCase();
93
- const expectedHosts = new Set(['localhost', '127.0.0.1']);
94
- const headerHostOnly = hostHeader.split(':')[0];
95
- if (!expectedHosts.has(headerHostOnly)) {
96
- this.logger.warn('DNS rebinding protection: invalid Host header', {
97
- hostHeader,
98
- expected: Array.from(expectedHosts),
99
- });
100
- res.status(403).json({ error: 'Forbidden' });
101
- return;
102
- }
103
- }
104
- next();
105
- });
106
- // JSON body parser
107
- // Limit set to 10MB to support large tool inputs/results (e.g. file content)
108
- // while preventing massive DoS attacks. Default is 100kb.
109
- this.app.use(express.json({ limit: '10mb' }));
110
- // Metrics: Track request start time
111
- this.app.use((req, res, next) => {
112
- req.startTime = Date.now();
113
- // Log response
114
- const originalSend = res.send;
115
- const originalJson = res.json;
116
- const logger = this.logger;
117
- res.send = function (body) {
118
- logger.debug('Outgoing response', {
119
- method: req.method,
120
- url: req.url,
121
- status: res.statusCode,
122
- contentType: res.get('content-type'),
123
- bodyLength: body ? body.length : 0,
124
- bodyPreview: typeof body === 'string' ? body.substring(0, 200) : '[object]'
125
- });
126
- return originalSend.call(this, body);
127
- };
128
- res.json = function (body) {
129
- logger.debug('Outgoing JSON response', {
130
- method: req.method,
131
- url: req.url,
132
- status: res.statusCode,
133
- body
134
- });
135
- return originalJson.call(this, body);
136
- };
137
- next();
138
- });
139
- // Debug: Log all requests
140
- this.app.use((req, res, next) => {
141
- this.logger.debug('Incoming request', {
142
- method: req.method,
143
- url: req.url,
144
- path: req.path,
145
- headers: {
146
- 'user-agent': req.headers['user-agent'],
147
- 'accept': req.headers.accept,
148
- 'content-type': req.headers['content-type'],
149
- 'authorization': req.headers.authorization ? '[REDACTED]' : undefined
150
- },
151
- ip: req.ip
152
- });
153
- next();
154
- });
155
- // Security: Origin validation (DNS rebinding protection)
156
- this.app.use((req, res, next) => {
157
- const origin = req.headers.origin;
158
- // Warn if binding to 0.0.0.0
159
- if (this.config.host === '0.0.0.0' && !this.hasWarnedAboutBinding) {
160
- this.logger.warn('HTTP transport bound to 0.0.0.0 - accessible from network. Ensure firewall protection.');
161
- this.hasWarnedAboutBinding = true;
162
- }
163
- // Validate Origin header
164
- // We do not skip this check for localhost, as requests to localhost can still be CSRF targets
165
- if (!origin) {
166
- next();
167
- return;
168
- }
169
- this.isAllowedOriginForRequest(origin, req)
170
- .then((allowed) => {
171
- if (!allowed) {
172
- this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
173
- res.status(HTTP_STATUS.FORBIDDEN).json({
174
- error: 'Forbidden',
175
- message: 'Origin not allowed'
176
- });
177
- return;
178
- }
179
- next();
180
- })
181
- .catch((error) => {
182
- this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
183
- res.status(HTTP_STATUS.FORBIDDEN).json({
184
- error: 'Forbidden',
185
- message: 'Origin not allowed'
186
- });
187
- });
188
- });
189
- // Extract session ID from header
190
- this.app.use((req, res, next) => {
191
- const sessionId = req.headers['mcp-session-id'];
192
- if (sessionId) {
193
- req.sessionId = sessionId;
194
- }
195
- next();
196
- });
197
- }
198
- setProfileContextProvider(provider) {
199
- this.profileContextProvider = provider;
200
- }
201
- getDefaultProfileId() {
202
- if (this.config.defaultProfileId) {
203
- return this.config.defaultProfileId;
204
- }
205
- if (this.config.profileRoutingEnabled) {
206
- return undefined;
207
- }
208
- return 'default';
209
- }
210
- buildDefaultProfileContext() {
211
- const profileId = this.getDefaultProfileId();
212
- if (!profileId) {
213
- return null;
214
- }
215
- return {
216
- profileId,
217
- oauthConfig: this.config.oauthConfig,
218
- authConfigs: this.config.authConfigs,
219
- baseUrl: this.config.baseUrl,
220
- rateLimitOAuthMax: this.config.rateLimitOAuthMax,
221
- rateLimitOAuthWindowMs: this.config.rateLimitOAuthWindowMs,
222
- resourceName: this.config.resourceName,
223
- resourceDocumentation: this.config.resourceDocumentation,
224
- parser: this.config.parser,
225
- };
226
- }
227
- async getProfileState(profileId) {
228
- const existing = this.profileStates.get(profileId);
229
- if (existing) {
230
- return existing;
231
- }
232
- let context = null;
233
- if (this.profileContextProvider) {
234
- try {
235
- context = await this.profileContextProvider(profileId);
236
- }
237
- catch (error) {
238
- if (error instanceof ConfigurationError && error.message === 'Profile not found') {
239
- this.logger.warn('Profile not found during request', { profileId });
240
- return null;
241
- }
242
- throw error;
243
- }
244
- }
245
- else {
246
- const defaultContext = this.buildDefaultProfileContext();
247
- if (defaultContext?.profileId === profileId) {
248
- context = defaultContext;
249
- }
250
- }
251
- if (!context) {
252
- return null;
253
- }
254
- let oauthProvider = null;
255
- if (context.oauthConfig) {
256
- this.logger.info('Initializing OAuth provider with config', {
257
- profileId,
258
- hasClientId: !!context.oauthConfig.client_id,
259
- });
260
- oauthProvider = new ExternalOAuthProvider(context.oauthConfig, this.logger);
261
- this.logger.info('OAuth provider initialized', {
262
- profileId,
263
- endpoint: oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
264
- hasIssuer: !!context.oauthConfig.issuer,
265
- });
266
- }
267
- else {
268
- this.logger.info('No OAuth config provided - OAuth provider not initialized', { profileId });
269
- }
270
- const state = {
271
- profileId,
272
- context,
273
- oauthProvider,
274
- oauthTokensByAccessToken: new Map(),
275
- sessions: new Map(),
276
- };
277
- this.profileStates.set(profileId, state);
278
- return state;
279
- }
280
- getProfileIdForRequest(req) {
281
- if (req.profileId) {
282
- return req.profileId;
283
- }
284
- return this.getDefaultProfileId();
285
- }
286
- async getProfileStateForRequest(req) {
287
- const profileId = this.getProfileIdForRequest(req);
288
- if (!profileId) {
289
- return null;
290
- }
291
- return await this.getProfileState(profileId);
292
- }
293
- /**
294
- * Check if origin is allowed
295
- *
296
- * Why: Prevent DNS rebinding attacks
297
- *
298
- * Supports:
299
- * - Exact hostname: 'example.com', 'api.example.com'
300
- * - Wildcard subdomain: '*.example.com'
301
- * - IPv4 CIDR: '192.168.1.0/24', '10.0.0.0/8'
302
- * - IPv4 exact: '192.168.1.100'
303
- */
304
- isAllowedOrigin(origin) {
305
- try {
306
- const url = new URL(origin);
307
- const hostname = url.hostname;
308
- // Always allow localhost
309
- if (hostname === 'localhost' || hostname === '127.0.0.1') {
310
- return true;
311
- }
312
- // Allow configured host
313
- if (hostname === this.config.host) {
314
- return true;
315
- }
316
- // Allow OAuth redirect URI hosts (initialized + cached + configured)
317
- for (const redirectHost of this.getOAuthRedirectHostPatterns()) {
318
- if (this.matchOrigin(hostname, redirectHost)) {
319
- return true;
320
- }
321
- }
322
- // Check custom allowed origins
323
- if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) {
324
- for (const allowed of this.config.allowedOrigins) {
325
- if (this.matchOrigin(hostname, allowed)) {
326
- return true;
327
- }
328
- }
329
- }
330
- return false;
331
- }
332
- catch {
333
- return false;
334
- }
335
- }
336
- getOAuthRedirectHostPatterns() {
337
- const hosts = new Set();
338
- const defaultProfileId = this.getDefaultProfileId() ?? 'default';
339
- for (const state of this.profileStates.values()) {
340
- const redirectUri = state.oauthProvider?.redirectUri;
341
- if (!redirectUri) {
342
- continue;
343
- }
344
- try {
345
- const redirectUrl = new URL(redirectUri);
346
- hosts.add(redirectUrl.hostname);
347
- }
348
- catch {
349
- // Ignore invalid URL
350
- }
351
- }
352
- if (this.config.oauthConfig) {
353
- for (const pattern of this.extractRedirectHostPatterns(this.config.oauthConfig, defaultProfileId)) {
354
- hosts.add(pattern);
355
- }
356
- }
357
- for (const patterns of this.oauthRedirectHostCache.values()) {
358
- for (const pattern of patterns) {
359
- hosts.add(pattern);
360
- }
361
- }
362
- return Array.from(hosts);
363
- }
364
- extractRedirectHostPatterns(oauthConfig, profileId) {
365
- if (!oauthConfig?.redirect_uri) {
366
- return [];
367
- }
368
- const resolvedRedirectUri = this.resolveRedirectUriFromEnv(oauthConfig.redirect_uri, profileId);
369
- if (!resolvedRedirectUri) {
370
- return [];
371
- }
372
- try {
373
- const redirectUrl = new URL(resolvedRedirectUri);
374
- return [redirectUrl.hostname];
375
- }
376
- catch {
377
- return [];
378
- }
379
- }
380
- resolveRedirectUriFromEnv(value, profileId) {
381
- const match = value.match(/^\$\{env:([^}]+)\}$/);
382
- if (!match) {
383
- return value;
384
- }
385
- const envVar = match[1];
386
- const envValue = process.env[envVar];
387
- if (!envValue || envValue.trim().length === 0) {
388
- const warningKey = `${profileId}:${envVar}`;
389
- if (!this.warnedMissingOAuthRedirectEnvVars.has(warningKey)) {
390
- this.warnedMissingOAuthRedirectEnvVars.add(warningKey);
391
- this.logger.warn('OAuth redirect_uri environment variable is empty', {
392
- profileId,
393
- envVar,
394
- });
395
- }
396
- return undefined;
397
- }
398
- return envValue;
399
- }
400
- resolveProfileIdFromPath(pathname) {
401
- if (!this.config.profileRoutingEnabled) {
402
- return null;
403
- }
404
- const match = /^\/profile\/([^/]+)/.exec(pathname);
405
- if (!match) {
406
- return null;
407
- }
408
- try {
409
- return decodeURIComponent(match[1]);
410
- }
411
- catch {
412
- return null;
413
- }
414
- }
415
- resolveProfileIdForOriginCheck(req) {
416
- const pathProfileId = this.resolveProfileIdFromPath(req.path);
417
- if (pathProfileId) {
418
- return pathProfileId;
419
- }
420
- const resource = req.query?.resource;
421
- if (typeof resource === 'string') {
422
- const profileId = this.resolveProfileIdFromResourceUrl(resource);
423
- if (profileId) {
424
- return profileId;
425
- }
426
- }
427
- return this.getDefaultProfileId() ?? null;
428
- }
429
- async primeOAuthRedirectHosts(profileId) {
430
- if (!this.profileContextProvider) {
431
- return;
432
- }
433
- if (this.oauthRedirectHostCache.has(profileId)) {
434
- return;
435
- }
436
- try {
437
- const context = await this.profileContextProvider(profileId);
438
- if (!context?.oauthConfig) {
439
- this.oauthRedirectHostCache.set(profileId, []);
440
- return;
441
- }
442
- const patterns = this.extractRedirectHostPatterns(context.oauthConfig, profileId);
443
- this.oauthRedirectHostCache.set(profileId, patterns);
444
- }
445
- catch (error) {
446
- if (error instanceof ConfigurationError && error.message === 'Profile not found') {
447
- return;
448
- }
449
- this.logger.warn('Failed to preload OAuth redirect hosts', {
450
- profileId,
451
- error: String(error),
452
- });
453
- }
454
- }
455
- async isAllowedOriginForRequest(origin, req) {
456
- if (this.isAllowedOrigin(origin)) {
457
- return true;
458
- }
459
- if (!this.config.profileRoutingEnabled) {
460
- return false;
461
- }
462
- const profileId = this.resolveProfileIdForOriginCheck(req);
463
- if (!profileId) {
464
- return false;
465
- }
466
- await this.primeOAuthRedirectHosts(profileId);
467
- return this.isAllowedOrigin(origin);
468
- }
469
- /**
470
- * Match hostname against allowed origin pattern
471
- *
472
- * Supports:
473
- * - Exact match: 'example.com' === 'example.com'
474
- * - Wildcard: '*.example.com' matches 'api.example.com', 'web.example.com'
475
- * - CIDR: '192.168.1.0/24' matches '192.168.1.1' through '192.168.1.254'
476
- */
477
- matchOrigin(hostname, pattern) {
478
- const normalizedHost = this.stripIpv6Brackets(hostname);
479
- const normalizedPattern = this.stripIpv6Brackets(pattern);
480
- // Exact match
481
- if (normalizedHost === normalizedPattern) {
482
- return true;
483
- }
484
- // Wildcard subdomain match (*.example.com)
485
- if (normalizedPattern.startsWith('*.')) {
486
- const domain = normalizedPattern.substring(2); // Remove '*.'
487
- return normalizedHost.endsWith('.' + domain) || normalizedHost === domain;
488
- }
489
- // CIDR match (IPv4/IPv6)
490
- if (normalizedPattern.includes('/')) {
491
- return this.matchCIDR(normalizedHost, normalizedPattern);
492
- }
493
- return false;
494
- }
495
- /**
496
- * Check if IP address is within CIDR range (IPv4 or IPv6)
497
- *
498
- * Example: '192.168.1.50' matches '192.168.1.0/24'
499
- * '2001:db8::1' matches '2001:db8::/32'
500
- */
501
- matchCIDR(ip, cidr) {
502
- const [rawRange, bits] = cidr.split('/');
503
- const range = this.stripIpv6Brackets(rawRange);
504
- const maskBits = parseInt(bits, 10);
505
- if (isNaN(maskBits)) {
506
- return false;
507
- }
508
- const ipVersion = isIP(ip);
509
- const rangeVersion = isIP(range);
510
- if (ipVersion === 0 || rangeVersion === 0 || ipVersion !== rangeVersion) {
511
- return false;
512
- }
513
- if (ipVersion === 4) {
514
- if (maskBits < 0 || maskBits > 32) {
515
- this.logger.warn('Invalid CIDR mask bits', { cidr });
516
- return false;
517
- }
518
- const ipInt = this.ipv4ToInt(ip);
519
- const rangeInt = this.ipv4ToInt(range);
520
- /* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
521
- * This should never happen in practice, but serves as a fail-safe */
522
- if (ipInt === null || rangeInt === null) {
523
- return false;
524
- }
525
- /* c8 ignore end */
526
- const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
527
- return (ipInt & mask) === (rangeInt & mask);
528
- }
529
- if (maskBits < 0 || maskBits > 128) {
530
- this.logger.warn('Invalid IPv6 CIDR mask bits', { cidr });
531
- return false;
532
- }
533
- const ipInt = this.ipv6ToBigInt(ip);
534
- const rangeInt = this.ipv6ToBigInt(range);
535
- /* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
536
- * This should never happen in practice, but serves as a fail-safe */
537
- if (ipInt === null || rangeInt === null) {
538
- return false;
539
- }
540
- /* c8 ignore end */
541
- const mask = this.ipv6Mask(maskBits);
542
- return (ipInt & mask) === (rangeInt & mask);
543
- }
544
- /**
545
- * Convert IPv4 address to 32-bit integer
546
- *
547
- * Example: '192.168.1.1' -> 3232235777
548
- */
549
- ipv4ToInt(ip) {
550
- const parts = ip.split('.');
551
- if (parts.length !== 4) {
552
- return null;
553
- }
554
- let result = 0;
555
- for (let i = 0; i < 4; i++) {
556
- const octet = parseInt(parts[i], 10);
557
- if (isNaN(octet) || octet < 0 || octet > 255) {
558
- return null;
559
- }
560
- result = (result << 8) | octet;
561
- }
562
- return result >>> 0; // Unsigned
563
- }
564
- /**
565
- * Convert IPv6 address to 128-bit BigInt
566
- */
567
- ipv6ToBigInt(ip) {
568
- const cleaned = this.stripIpv6Brackets(ip);
569
- // Handle IPv4-mapped IPv6 (e.g., ::ffff:192.168.0.1)
570
- let ipv4Tail = null;
571
- let base = cleaned;
572
- if (cleaned.includes('.')) {
573
- const lastColon = cleaned.lastIndexOf(':');
574
- if (lastColon === -1)
575
- return null;
576
- const ipv4Part = cleaned.slice(lastColon + 1);
577
- ipv4Tail = this.ipv4ToInt(ipv4Part);
578
- if (ipv4Tail === null)
579
- return null;
580
- base = cleaned.slice(0, lastColon);
581
- }
582
- const parts = base.split('::');
583
- if (parts.length > 2) {
584
- return null;
585
- }
586
- const head = parts[0] ? parts[0].split(':') : [];
587
- const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
588
- if (head.some(p => p === '') || tail.some(p => p === '')) {
589
- return null;
590
- }
591
- const totalSegmentsNeeded = 8 - (ipv4Tail !== null ? 2 : 0);
592
- let segments = [];
593
- const parseHextets = (items) => {
594
- const result = [];
595
- for (const part of items) {
596
- const num = parseInt(part || '0', 16);
597
- if (isNaN(num) || num < 0 || num > 0xFFFF) {
598
- return null;
599
- }
600
- result.push(num);
601
- }
602
- return result;
603
- };
604
- const headVals = parseHextets(head);
605
- const tailVals = parseHextets(tail);
606
- if (!headVals || !tailVals) {
607
- return null;
608
- }
609
- const missing = totalSegmentsNeeded - (headVals.length + tailVals.length);
610
- if (missing < 0) {
611
- return null;
612
- }
613
- segments = [...headVals, ...Array(missing).fill(0), ...tailVals];
614
- /* c8 ignore start - defensive check that should never trigger if logic above is correct */
615
- if (segments.length !== totalSegmentsNeeded) {
616
- return null;
617
- }
618
- /* c8 ignore end */
619
- if (ipv4Tail !== null) {
620
- const high = (ipv4Tail >>> 16) & 0xFFFF;
621
- const low = ipv4Tail & 0xFFFF;
622
- segments.push(high, low);
623
- }
624
- /* c8 ignore start - defensive check that should never trigger if logic above is correct */
625
- if (segments.length !== 8) {
626
- return null;
627
- }
628
- /* c8 ignore end */
629
- let value = 0n;
630
- for (const part of segments) {
631
- value = (value << 16n) + BigInt(part);
632
- }
633
- return value;
634
- }
635
- ipv6Mask(maskBits) {
636
- if (maskBits === 0) {
637
- return 0n;
638
- }
639
- const ones = (1n << BigInt(maskBits)) - 1n;
640
- return BigInt.asUintN(128, ones << BigInt(128 - maskBits));
641
- }
642
- stripIpv6Brackets(value) {
643
- return value.replace(/^\[/, '').replace(/\]$/, '');
644
- }
645
- /**
646
- * Create configured rate limiter or a passthrough handler when disabled
647
- *
648
- * Why: Both MCP and metrics endpoints share the same rate limiting setup logic.
649
- * Centralizing it keeps behaviour consistent and avoids drifting configuration.
650
- */
651
- createRateLimiter(options) {
652
- if (!options.enabled) {
653
- return (_req, _res, next) => next();
654
- }
655
- const message = options.responseMessage ??
656
- `Rate limit exceeded. Max ${options.maxRequests} requests per ${options.windowMs / 1000} seconds.`;
657
- return rateLimit({
658
- windowMs: options.windowMs,
659
- max: options.maxRequests,
660
- standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
661
- legacyHeaders: false, // Disable deprecated `X-RateLimit-*` headers
662
- handler: (req, res) => {
663
- this.logger.warn(options.logMessage, {
664
- ip: req.ip,
665
- path: req.path,
666
- method: req.method,
667
- });
668
- res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({
669
- error: 'Too Many Requests',
670
- message,
671
- });
672
- },
673
- });
674
- }
675
- formatRateLimitMessage(scope, maxRequests, windowMs) {
676
- return `Rate limit exceeded for ${scope}. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`;
677
- }
678
- getProfilePrefix(profileId, options) {
679
- if (!this.config.profileRoutingEnabled) {
680
- return '';
681
- }
682
- if (options?.forceProfilePrefix && profileId) {
683
- return `/profile/${encodeURIComponent(profileId)}`;
684
- }
685
- const defaultProfileId = this.getDefaultProfileId();
686
- if (!profileId || (defaultProfileId && profileId === defaultProfileId)) {
687
- return '';
688
- }
689
- return `/profile/${encodeURIComponent(profileId)}`;
690
- }
691
- buildProfilePath(profileId, path, options) {
692
- const prefix = this.getProfilePrefix(profileId, options);
693
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
694
- return `${prefix}${normalizedPath}`;
695
- }
696
- getServerOrigin(profileId) {
697
- if (profileId) {
698
- const state = this.profileStates.get(profileId);
699
- if (state?.oauthProvider?.redirectUri) {
700
- try {
701
- return new URL(state.oauthProvider.redirectUri).origin;
702
- }
703
- catch {
704
- // Ignore invalid URL
705
- }
706
- }
707
- }
708
- const protocol = this.config.host.includes('://') ? '' : 'http://';
709
- const host = this.config.host.includes('://') ? this.config.host : this.config.host;
710
- return `${protocol}${host}:${this.config.port}`;
711
- }
712
- buildProfileUrl(profileId, path, options) {
713
- return `${this.getServerOrigin(profileId)}${this.buildProfilePath(profileId, path, options)}`;
714
- }
715
- normalizeResourcePath(pathname) {
716
- const normalized = pathname.replace(/\/+$/, '');
717
- return normalized === '' ? '/' : normalized;
718
- }
719
- resolveProfileIdFromResourceUrl(resource) {
720
- let url;
721
- try {
722
- url = new URL(resource);
723
- }
724
- catch {
725
- return null;
726
- }
727
- const path = this.normalizeResourcePath(url.pathname);
728
- if (path === '/mcp') {
729
- return this.getDefaultProfileId() ?? null;
730
- }
731
- if (!this.config.profileRoutingEnabled) {
732
- return null;
733
- }
734
- const match = /^\/profile\/([^/]+)\/mcp$/.exec(path);
735
- if (!match) {
736
- return null;
737
- }
738
- try {
739
- return decodeURIComponent(match[1]);
740
- }
741
- catch {
742
- return null;
743
- }
744
- }
745
- getOAuthProtectedResourceUrl(profileId) {
746
- const effectiveProfileId = profileId ?? this.getDefaultProfileId();
747
- return this.buildProfileUrl(effectiveProfileId, OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE);
748
- }
749
- respondProfileNotFound(res, profileId) {
750
- res.status(HTTP_STATUS.NOT_FOUND).json({
751
- error: 'Not Found',
752
- message: profileId ? `Profile '${profileId}' not found` : 'Profile not found',
753
- });
754
- }
755
- /**
756
- * Setup MCP endpoint routes
757
- *
758
- * Why: Single endpoint for POST (client→server) and GET (SSE stream)
759
- */
760
- setupRoutes() {
761
- this.logger.info('Setting up HTTP routes');
762
- // Security: Rate limiting setup (needed for OAuth routes)
763
- const rateLimitEnabled = this.config.rateLimitEnabled !== false; // default: true
764
- const profileRoutingEnabled = this.config.profileRoutingEnabled === true;
765
- const defaultProfileId = this.getDefaultProfileId();
766
- const attachProfileId = (req, _res, next) => {
767
- req.profileId = req.params.profileId;
768
- next();
769
- };
770
- const oauthRateLimiterByProfile = new Map();
771
- const getOAuthRateLimiter = (profileState) => {
772
- const existing = oauthRateLimiterByProfile.get(profileState.profileId);
773
- if (existing) {
774
- return existing;
775
- }
776
- const oauthWindowMs = profileState.context.rateLimitOAuthWindowMs || OAUTH_RATE_LIMIT.WINDOW_MS;
777
- const oauthMaxRequests = profileState.context.rateLimitOAuthMax || OAUTH_RATE_LIMIT.MAX_REQUESTS;
778
- const limiter = this.createRateLimiter({
779
- enabled: rateLimitEnabled,
780
- windowMs: oauthWindowMs,
781
- maxRequests: oauthMaxRequests,
782
- logMessage: 'Rate limit exceeded for OAuth',
783
- responseMessage: `Too many OAuth requests. Limit: ${oauthMaxRequests} requests per ${Math.round(oauthWindowMs / 60000)} minutes. Please try again later.`,
784
- });
785
- oauthRateLimiterByProfile.set(profileState.profileId, limiter);
786
- return limiter;
787
- };
788
- const oauthRateLimiter = async (req, res, next) => {
789
- const profileState = await this.getProfileStateForRequest(req);
790
- if (!profileState) {
791
- this.respondProfileNotFound(res, req.profileId);
792
- return;
793
- }
794
- const limiter = getOAuthRateLimiter(profileState);
795
- return limiter(req, res, next);
796
- };
797
- const withProfileState = (handler) => {
798
- return async (req, res) => {
799
- const profileState = await this.getProfileStateForRequest(req);
800
- if (!profileState) {
801
- this.respondProfileNotFound(res, req.profileId);
802
- return;
803
- }
804
- await handler(req, res, profileState);
805
- };
806
- };
807
- const registerOAuthRoutes = (basePath, includeProfileParam, includeProtectedResource) => {
808
- const middlewares = includeProfileParam ? [attachProfileId] : [];
809
- const withOAuthRateLimit = [...middlewares, oauthRateLimiter];
810
- const withProfile = (handler) => {
811
- return [...middlewares, oauthRateLimiter, withProfileState(handler)];
812
- };
813
- if (includeProtectedResource) {
814
- this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE}`, ...withProfile((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
815
- }
816
- this.app.get(`${basePath}${OAUTH_PATHS.AUTHORIZE}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
817
- this.app.post(`${basePath}${OAUTH_PATHS.TOKEN}`, ...middlewares, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
818
- this.app.get(`${basePath}${OAUTH_PATHS.CALLBACK}`, ...withProfile((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
819
- this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
820
- this.app.post(`${basePath}${OAUTH_PATHS.REGISTER}`, ...middlewares, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
821
- this.logger.info('OAuth routes registered', {
822
- basePath,
823
- profileRoutingEnabled,
824
- });
825
- };
826
- if (defaultProfileId) {
827
- registerOAuthRoutes('', false, false);
828
- }
829
- if (profileRoutingEnabled) {
830
- registerOAuthRoutes('/profile/:profileId', true, true);
831
- }
832
- const attachProfileFromResourceQuery = (req, res, next) => {
833
- const { resource } = req.query;
834
- if (resource === undefined) {
835
- next();
836
- return;
837
- }
838
- if (typeof resource !== 'string') {
839
- res.status(HTTP_STATUS.BAD_REQUEST).json({
840
- error: 'invalid_request',
841
- message: 'Invalid resource query parameter',
842
- });
843
- return;
844
- }
845
- const profileId = this.resolveProfileIdFromResourceUrl(resource);
846
- if (!profileId) {
847
- res.status(HTTP_STATUS.NOT_FOUND).json({
848
- error: 'Not Found',
849
- message: 'OAuth metadata unavailable for requested resource',
850
- });
851
- return;
852
- }
853
- req.profileId = profileId;
854
- next();
855
- };
856
- if (defaultProfileId || profileRoutingEnabled) {
857
- this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
858
- }
859
- // Security: Rate limiting setup (for MCP endpoints)
860
- const windowMs = this.config.rateLimitWindowMs || TIMEOUTS.RATE_LIMIT_WINDOW_MS;
861
- const maxRequests = this.config.rateLimitMaxRequests || 100; // 100 req/min
862
- const metricsMaxRequests = this.config.rateLimitMetricsMax || 10; // 10 req/min for metrics
863
- if (rateLimitEnabled) {
864
- this.logger.info('Rate limiting enabled', {
865
- windowMs,
866
- maxRequests,
867
- metricsMaxRequests,
868
- });
869
- }
870
- // Rate limiter for MCP/SSE endpoints (100 req/min by default)
871
- const mcpRateLimiter = this.createRateLimiter({
872
- enabled: rateLimitEnabled,
873
- windowMs,
874
- maxRequests,
875
- logMessage: 'Rate limit exceeded',
876
- });
877
- // Rate limiter for metrics endpoint (10 req/min by default)
878
- const metricsRateLimiter = this.createRateLimiter({
879
- enabled: rateLimitEnabled,
880
- windowMs,
881
- maxRequests: metricsMaxRequests,
882
- logMessage: 'Rate limit exceeded for metrics',
883
- responseMessage: this.formatRateLimitMessage('metrics', metricsMaxRequests, windowMs),
884
- });
885
- const registerMcpRoutes = (basePath, includeProfileParam, isDefault) => {
886
- const middlewares = includeProfileParam ? [attachProfileId] : [];
887
- const pathPrefix = basePath || '';
888
- // Main MCP endpoint - POST for sending messages
889
- this.app.post(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handlePost.bind(this));
890
- // CORS preflight handler
891
- this.app.options(`${pathPrefix}/mcp`, ...middlewares, async (req, res) => {
892
- const origin = req.headers.origin;
893
- try {
894
- // Only send CORS headers for explicitly allowed origins; otherwise reject
895
- if (origin && await this.isAllowedOriginForRequest(origin, req)) {
896
- // nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration
897
- res.setHeader('Access-Control-Allow-Origin', origin);
898
- res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
899
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id');
900
- // We do not allow credentials; prevents cookie-based attacks by default
901
- res.setHeader('Access-Control-Allow-Credentials', 'false');
902
- res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache
903
- return res.status(HTTP_STATUS.OK).send();
904
- }
905
- // Disallowed origin: do not echo origin or emit permissive headers
906
- res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
907
- }
908
- catch (error) {
909
- this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
910
- res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
911
- }
912
- });
913
- // Main MCP endpoint - GET for SSE streaming
914
- this.app.get(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleGet.bind(this));
915
- // Session termination
916
- this.app.delete(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleDelete.bind(this));
917
- // Legacy alias endpoints - deprecated
918
- // Why: Backward compatibility for clients using /sse during migration
919
- this.app.post(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
920
- this.logger.warn('Deprecated endpoint used: POST /sse. Please migrate to POST /mcp');
921
- this.logger.info('Handling POST /sse request');
922
- return this.handlePost(req, res, next);
923
- });
924
- this.app.get(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
925
- this.logger.warn('Deprecated endpoint used: GET /sse. Please migrate to GET /mcp');
926
- this.logger.info(`Handling GET /sse request from: ${req.ip}`);
927
- return this.handleGet(req, res, next);
928
- });
929
- this.app.delete(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
930
- this.logger.warn('Deprecated endpoint used: DELETE /sse. Please migrate to DELETE /mcp');
931
- return this.handleDelete(req, res, next);
932
- });
933
- if (isDefault) {
934
- this.logger.info('Registered MCP routes for default profile', { pathPrefix: pathPrefix || '/mcp' });
935
- }
936
- else {
937
- this.logger.info('Registered MCP routes for profile routing', { pathPrefix: `${pathPrefix}/mcp` });
938
- }
939
- };
940
- if (defaultProfileId) {
941
- registerMcpRoutes('', false, true);
942
- }
943
- if (profileRoutingEnabled) {
944
- registerMcpRoutes('/profile/:profileId', true, false);
945
- }
946
- // Metrics endpoint (if enabled)
947
- if (this.config.metricsEnabled) {
948
- this.app.get(this.config.metricsPath, metricsRateLimiter, this.handleMetrics.bind(this));
949
- }
950
- // Health check (with rate limiting)
951
- this.app.get('/health', mcpRateLimiter, (req, res) => {
952
- const startTime = Date.now();
953
- let totalSessions = 0;
954
- for (const state of this.profileStates.values()) {
955
- totalSessions += state.sessions.size;
956
- }
957
- res.json({ status: 'ok', sessions: totalSessions });
958
- if (this.metrics) {
959
- const duration = (Date.now() - startTime) / 1000;
960
- this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
961
- }
962
- });
963
- // Debug: SSE route registered
964
- this.logger.info('SSE routes registered successfully');
965
- // Default 404 handler - MUST be last route registered
966
- // This will catch all unmatched requests
967
- this.app.use((req, res) => {
968
- this.logger.warn('Unhandled request (404)', {
969
- method: req.method,
970
- url: req.url,
971
- path: req.path,
972
- headers: req.headers,
973
- ip: req.ip
974
- });
975
- res.status(HTTP_STATUS.NOT_FOUND).json({
976
- error: 'Not Found',
977
- message: `Endpoint ${req.method} ${req.path} not found`
978
- });
979
- });
980
- }
981
- getProfileIssuerUrl(profileId, options) {
982
- const origin = this.getServerOrigin(profileId);
983
- const prefix = this.getProfilePrefix(profileId, options);
984
- return `${origin}${prefix}`;
985
- }
986
- async handleOAuthProtectedResource(req, res, profileState) {
987
- if (!profileState.oauthProvider) {
988
- res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'OAuth not configured for this profile' });
989
- return;
990
- }
991
- const profileId = profileState.profileId;
992
- const isProfileScoped = typeof req.params?.profileId === 'string';
993
- const urlOptions = isProfileScoped ? { forceProfilePrefix: true } : undefined;
994
- const serverUrl = new URL(this.buildProfileUrl(profileId, '/mcp', urlOptions));
995
- const issuerUrl = this.getProfileIssuerUrl(profileId, urlOptions);
996
- const metadata = {
997
- resource: serverUrl.href,
998
- authorization_servers: [issuerUrl],
999
- bearer_methods_supported: ['header'],
1000
- };
1001
- if (profileState.oauthProvider.scopes && profileState.oauthProvider.scopes.length > 0) {
1002
- metadata.scopes_supported = profileState.oauthProvider.scopes;
1003
- }
1004
- if (profileState.context.resourceName) {
1005
- metadata.resource_name = profileState.context.resourceName;
1006
- }
1007
- if (profileState.context.resourceDocumentation) {
1008
- metadata.resource_documentation = profileState.context.resourceDocumentation;
1009
- }
1010
- res.json(metadata);
1011
- }
1012
- async handleOAuthAuthorize(req, res, profileState) {
1013
- if (!profileState.oauthProvider) {
1014
- res.status(HTTP_STATUS.NOT_FOUND).send('OAuth not configured for this profile');
1015
- return;
1016
- }
1017
- try {
1018
- res.setHeader('Cache-Control', 'no-store');
1019
- const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
1020
- if (!client_id || typeof client_id !== 'string') {
1021
- res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
1022
- return;
1023
- }
1024
- if (!response_type || typeof response_type !== 'string') {
1025
- res.status(HTTP_STATUS.BAD_REQUEST).send('Missing response_type');
1026
- return;
1027
- }
1028
- if (response_type !== 'code') {
1029
- res.status(HTTP_STATUS.BAD_REQUEST).send('Unsupported response_type');
1030
- return;
1031
- }
1032
- if (!redirect_uri || typeof redirect_uri !== 'string') {
1033
- res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
1034
- return;
1035
- }
1036
- await profileState.oauthProvider.ensureEndpointsInitialized();
1037
- const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
1038
- if (!client) {
1039
- res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
1040
- return;
1041
- }
1042
- const scopeStr = (scope || '').trim();
1043
- const params = {
1044
- responseType: response_type,
1045
- clientId: client_id,
1046
- redirectUri: redirect_uri,
1047
- scope: scopeStr ? scopeStr.split(' ') : [],
1048
- state: state,
1049
- codeChallenge: code_challenge,
1050
- codeChallengeMethod: code_challenge_method,
1051
- scopes: scopeStr ? scopeStr.split(' ') : [],
1052
- };
1053
- await profileState.oauthProvider.authorize(client, params, res);
1054
- }
1055
- catch (error) {
1056
- this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
1057
- res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
1058
- }
1059
- }
1060
- async handleOAuthToken(req, res, profileState) {
1061
- if (!profileState.oauthProvider) {
1062
- res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
1063
- return;
1064
- }
1065
- try {
1066
- res.setHeader('Cache-Control', 'no-store');
1067
- const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
1068
- this.logger.debug('OAuth token request', {
1069
- profileId: profileState.profileId,
1070
- grant_type,
1071
- client_id,
1072
- has_code: !!code,
1073
- has_code_verifier: !!code_verifier,
1074
- redirect_uri,
1075
- });
1076
- if (grant_type === 'authorization_code') {
1077
- if (!code) {
1078
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
1079
- return;
1080
- }
1081
- await profileState.oauthProvider.ensureEndpointsInitialized();
1082
- const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
1083
- if (!client) {
1084
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
1085
- return;
1086
- }
1087
- const tokens = await profileState.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
1088
- this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
1089
- res.json(tokens);
1090
- return;
1091
- }
1092
- if (grant_type === 'refresh_token') {
1093
- if (!refresh_token) {
1094
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
1095
- return;
1096
- }
1097
- await profileState.oauthProvider.ensureEndpointsInitialized();
1098
- const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
1099
- if (!client) {
1100
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
1101
- return;
1102
- }
1103
- const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, refresh_token);
1104
- this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
1105
- res.json(tokens);
1106
- return;
1107
- }
1108
- this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
1109
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
1110
- }
1111
- catch (error) {
1112
- this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
1113
- res.status(HTTP_STATUS.BAD_REQUEST).json({
1114
- error: 'invalid_grant',
1115
- error_description: 'Token exchange failed',
1116
- });
1117
- }
1118
- }
1119
- async handleOAuthCallback(req, res, profileState) {
1120
- if (!profileState.oauthProvider) {
1121
- res.status(HTTP_STATUS.NOT_FOUND).send('OAuth provider not initialized');
1122
- return;
1123
- }
1124
- try {
1125
- const { code, state, error, error_description } = req.query;
1126
- this.logger.info('OAuth callback received', {
1127
- profileId: profileState.profileId,
1128
- hasCode: !!code,
1129
- hasState: !!state,
1130
- error: error,
1131
- errorDescription: error_description,
1132
- });
1133
- if (error) {
1134
- const safeError = escapeHtmlSafe(error);
1135
- const safeErrorDesc = escapeHtmlSafe(error_description);
1136
- res.status(HTTP_STATUS.BAD_REQUEST).json({
1137
- error: safeError,
1138
- error_description: safeErrorDesc || safeError,
1139
- });
1140
- return;
1141
- }
1142
- if (!code || typeof code !== 'string') {
1143
- res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
1144
- return;
1145
- }
1146
- await profileState.oauthProvider.handleCallback(req, res);
1147
- }
1148
- catch (error) {
1149
- this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
1150
- if (!res.headersSent) {
1151
- res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
1152
- }
1153
- }
1154
- }
1155
- async handleOAuthAuthorizationServerMetadata(req, res, profileState) {
1156
- if (!profileState.oauthProvider) {
1157
- res.status(HTTP_STATUS.NOT_FOUND).send('OAuth metadata unavailable');
1158
- return;
1159
- }
1160
- try {
1161
- const profileId = profileState.profileId;
1162
- const isProfileScoped = typeof req.params?.profileId === 'string';
1163
- const urlOptions = isProfileScoped ? { forceProfilePrefix: true } : undefined;
1164
- const issuer = this.getProfileIssuerUrl(profileId, urlOptions);
1165
- res.json({
1166
- issuer,
1167
- authorization_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.AUTHORIZE, urlOptions),
1168
- token_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.TOKEN, urlOptions),
1169
- registration_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.REGISTER, urlOptions),
1170
- response_types_supported: ['code'],
1171
- code_challenge_methods_supported: ['S256'],
1172
- grant_types_supported: ['authorization_code', 'refresh_token'],
1173
- scopes_supported: profileState.oauthProvider.scopes || ['api'],
1174
- });
1175
- }
1176
- catch (error) {
1177
- this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
1178
- res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
1179
- }
1180
- }
1181
- async handleOAuthRegister(req, res, profileState) {
1182
- if (!profileState.oauthProvider) {
1183
- res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'Registration unavailable' });
1184
- return;
1185
- }
1186
- try {
1187
- const { redirect_uris } = req.body;
1188
- this.logger.info('Dynamic client registration request', {
1189
- profileId: profileState.profileId,
1190
- redirect_uris,
1191
- });
1192
- const clientId = 'mcp-proxy-client';
1193
- const clientSecret = 'mcp-proxy-secret';
1194
- const client = {
1195
- client_id: clientId,
1196
- client_secret: clientSecret,
1197
- redirect_uris: redirect_uris || [],
1198
- grant_types: ['authorization_code', 'refresh_token'],
1199
- response_types: ['code'],
1200
- scope: (profileState.oauthProvider.scopes || []).join(' '),
1201
- };
1202
- await profileState.oauthProvider.clientsStore.registerClient(client);
1203
- res.status(HTTP_STATUS.CREATED).json({
1204
- client_id: clientId,
1205
- client_secret: clientSecret,
1206
- redirect_uris: redirect_uris,
1207
- grant_types: ['authorization_code', 'refresh_token'],
1208
- response_types: ['code'],
1209
- scope: (profileState.oauthProvider.scopes || []).join(' '),
1210
- token_endpoint_auth_method: 'client_secret_post',
1211
- });
1212
- }
1213
- catch (error) {
1214
- this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
1215
- res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
1216
- }
1217
- }
1218
- /**
1219
- * Handle metrics endpoint
1220
- *
1221
- * Why: Prometheus scraping endpoint
1222
- */
1223
- async handleMetrics(req, res) {
1224
- const startTime = Date.now();
1225
- try {
1226
- if (!this.metrics) {
1227
- res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Metrics disabled' });
1228
- return;
1229
- }
1230
- const metrics = await this.metrics.getMetrics();
1231
- res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
1232
- res.send(metrics);
1233
- // Don't record metrics call in metrics (avoid recursion)
1234
- }
1235
- catch (error) {
1236
- const correlationId = generateCorrelationId();
1237
- this.logger.error('Metrics endpoint error', error, { correlationId });
1238
- res.setHeader('Cache-Control', 'no-store');
1239
- res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
1240
- error: 'Internal Server Error',
1241
- message: `Internal error (correlation ID: ${correlationId})`,
1242
- correlationId
1243
- });
1244
- }
1245
- }
1246
- /**
1247
- * Validate authentication token by making a probe request to the API
1248
- *
1249
- * Supports all auth types: bearer, query, custom-header
1250
- * Returns true if token is valid, false otherwise
1251
- */
1252
- /**
1253
- * Builds a URL by intelligently combining base URL and endpoint
1254
- * Handles absolute URLs, absolute paths, and relative paths correctly
1255
- */
1256
- buildUrl(endpoint, baseUrl) {
1257
- // If endpoint is already an absolute URL, use it as-is
1258
- if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
1259
- return new URL(endpoint);
1260
- }
1261
- // If endpoint is an absolute path (starts with /), combine with origin of baseUrl
1262
- if (endpoint.startsWith('/')) {
1263
- const baseUrlObj = new URL(baseUrl);
1264
- return new URL(endpoint, baseUrlObj.origin);
1265
- }
1266
- // Otherwise, treat as relative path and append to baseUrl
1267
- // Ensure baseUrl ends with '/' for proper URL construction
1268
- const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
1269
- return new URL(endpoint, normalizedBaseUrl);
1270
- }
1271
- async validateAuthToken(authConfig, token, baseUrl) {
1272
- if (!authConfig.validation_endpoint) {
1273
- return true; // Skip validation if not configured
1274
- }
1275
- const url = this.buildUrl(authConfig.validation_endpoint, baseUrl);
1276
- const headers = {};
1277
- const method = authConfig.validation_method || 'GET';
1278
- const timeout = authConfig.validation_timeout_ms || 5000;
1279
- const urlString = url.toString();
1280
- // Apply auth based on type
1281
- switch (authConfig.type) {
1282
- case 'oauth':
1283
- case 'bearer':
1284
- headers['Authorization'] = `Bearer ${token}`;
1285
- break;
1286
- case 'custom-header':
1287
- if (authConfig.header_name) {
1288
- headers[authConfig.header_name] = token;
1289
- }
1290
- break;
1291
- case 'query':
1292
- if (authConfig.query_param) {
1293
- url.searchParams.set(authConfig.query_param, token);
1294
- }
1295
- break;
1296
- }
1297
- try {
1298
- this.logger.debug('Validating auth token', {
1299
- endpoint: urlString,
1300
- method,
1301
- authType: authConfig.type,
1302
- });
1303
- const controller = new AbortController();
1304
- const timeoutId = setTimeout(() => controller.abort(), timeout);
1305
- const response = await fetch(url.toString(), {
1306
- method,
1307
- headers,
1308
- signal: controller.signal,
1309
- });
1310
- clearTimeout(timeoutId);
1311
- const isValid = response.status >= 200 && response.status < 300;
1312
- this.logger.debug('Auth token validation result', {
1313
- status: response.status,
1314
- isValid,
1315
- });
1316
- return isValid;
1317
- }
1318
- catch (error) {
1319
- this.logger.warn(`Auth token validation failed: ${error.message}`, {
1320
- endpoint: authConfig.validation_endpoint,
1321
- });
1322
- return false;
1323
- }
1324
- }
1325
- /**
1326
- * Validate token format and length
1327
- *
1328
- * Why centralized: Single source of truth for token validation rules
1329
- *
1330
- * Relaxed validation: Allow common API token characters including colons,
1331
- * to support various token formats (GitLab glpat-, YouTrack perm:, etc.)
1332
- */
1333
- validateToken(token, source) {
1334
- const maxLength = this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH;
1335
- if (token.length > maxLength) {
1336
- throw new ValidationError(`${source} too long (max ${maxLength} characters)`);
1337
- }
1338
- if (token.length === 0) {
1339
- throw new ValidationError(`${source} is empty`);
1340
- }
1341
- // RFC 6750 Bearer token characters + common API token chars (including colons for YouTrack)
1342
- // Allow: alphanumeric, dash, underscore, dot, tilde, plus, slash, equals, colon
1343
- // Note: dash at end of character class to avoid being interpreted as range
1344
- if (!/^[A-Za-z0-9._~+/:=-]+$/.test(token)) {
1345
- throw new ValidationError(`Invalid ${source} format`);
1346
- }
1347
- }
1348
- /**
1349
- * Extract and validate auth token from request headers
1350
- *
1351
- * Supports:
1352
- * - Authorization: Bearer <token>
1353
- * - X-API-Token: <token>
1354
- * - OAuth session (via mcp-session-id header)
1355
- *
1356
- * Why strict validation: Prevents header injection attacks
1357
- *
1358
- * Returns: { type: 'bearer' | 'oauth' | 'api-token', token: string, sessionId?: string }
1359
- */
1360
- extractAuthToken(req, profileState) {
1361
- // 1. Check for OAuth session first (highest priority for authenticated sessions)
1362
- const sessionId = req.sessionId || req.headers['mcp-session-id'];
1363
- if (sessionId && profileState.sessions.has(sessionId)) {
1364
- const session = profileState.sessions.get(sessionId);
1365
- if (session && session.authToken) {
1366
- return { type: 'oauth', token: session.authToken, sessionId };
1367
- }
1368
- }
1369
- // 2. Check Authorization: Bearer header
1370
- const authHeader = req.headers.authorization;
1371
- if (authHeader) {
1372
- // Defense against ReDoS: Check length before regex
1373
- const maxHeaderLength = (this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH) + 10; // Bearer + spaces + margin
1374
- if (authHeader.length > maxHeaderLength) {
1375
- throw new ValidationError(`Authorization header too long (max ${maxHeaderLength} characters)`);
1376
- }
1377
- // Relaxed Bearer token format validation - allow flexible whitespace
1378
- // Trim whitespace to handle client variations (IntelliJ, VSCode, etc.)
1379
- const trimmed = authHeader.trim();
1380
- const match = trimmed.match(/^Bearer\s+(.+)$/);
1381
- if (!match) {
1382
- throw new ValidationError('Invalid Authorization header format. Expected: Bearer <token>');
1383
- }
1384
- const token = match[1].trim();
1385
- this.validateToken(token, 'Authorization token');
1386
- return { type: 'bearer', token };
1387
- }
1388
- // 3. Check X-API-Token header (for custom implementations)
1389
- const apiTokenHeader = req.headers['x-api-token'];
1390
- if (apiTokenHeader) {
1391
- if (typeof apiTokenHeader !== 'string') {
1392
- throw new ValidationError('X-API-Token must be a string');
1393
- }
1394
- this.validateToken(apiTokenHeader, 'X-API-Token');
1395
- return { type: 'api-token', token: apiTokenHeader };
1396
- }
1397
- return { type: 'none' };
1398
- }
1399
- /**
1400
- * Lazy initialization of ToolFilterService
1401
- */
1402
- getToolFilterService(profileState) {
1403
- if (!profileState.toolFilterService) {
1404
- const validator = new RegexValidator();
1405
- const compiler = new RegexCompiler(validator);
1406
- const envParser = new EnvConfigParser(compiler);
1407
- const headerParser = new HeaderConfigParser(compiler);
1408
- // Create OperationDetector for category filtering (if parser available)
1409
- let detector;
1410
- if (profileState.context.parser) {
1411
- const classifier = new OperationClassifier();
1412
- const resolver = new OpenAPIOperationResolver(profileState.context.parser);
1413
- detector = new OperationDetector(classifier, resolver);
1414
- }
1415
- profileState.toolFilterService = new ToolFilterService(envParser, headerParser, this.logger, detector);
1416
- }
1417
- return profileState.toolFilterService;
1418
- }
1419
- /**
1420
- * Handle POST requests - Client sending messages to server
1421
- *
1422
- * MCP Spec: POST can contain requests, notifications, or responses
1423
- */
1424
- async handlePost(req, res) {
1425
- const startTime = Date.now();
1426
- try {
1427
- this.logger.debug('handlePost called', { method: req.method, path: req.path, sessionId: req.sessionId, accept: req.headers.accept });
1428
- const profileState = await this.getProfileStateForRequest(req);
1429
- if (!profileState) {
1430
- this.respondProfileNotFound(res, req.profileId);
1431
- return;
1432
- }
1433
- const requestProfileId = req.profileId ?? profileState.profileId;
1434
- const sessionId = req.sessionId;
1435
- const body = req.body;
1436
- const filteringHeader = normalizeFilteringHeaderValue(this.getFilteringHeaderValue(req));
1437
- const parsedFiltering = filteringHeader ? parseFilteringHeader(filteringHeader) : undefined;
1438
- const toolFilterHeader = normalizeToolFilterHeaderValue(this.getToolFilterHeaderValue(req));
1439
- const parsedToolFilter = toolFilterHeader !== undefined ? parseSessionToolFilterHeader(toolFilterHeader) : undefined;
1440
- const normalizedToolFilterHeader = parsedToolFilter?.normalizedHeader;
1441
- // Validate Accept header per MCP Streamable HTTP specification
1442
- const accept = req.headers.accept || '';
1443
- // POST requests can return either JSON or SSE, so must accept both if specified
1444
- // GET requests return SSE, so must accept text/event-stream
1445
- const acceptsJson = accept.includes(MIME_TYPES.JSON) || accept === '*/*' || accept === '';
1446
- const acceptsEventStream = accept.includes(MIME_TYPES.EVENT_STREAM) || accept === '*/*' || accept === '';
1447
- if (req.method === 'GET' && accept && !acceptsEventStream) {
1448
- this.logger.debug('Accept header validation failed for GET, returning 406');
1449
- res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
1450
- error: 'Not Acceptable',
1451
- message: `GET requests must accept ${MIME_TYPES.EVENT_STREAM}`
1452
- });
1453
- return;
1454
- }
1455
- // For POST, be more flexible - allow if client accepts either JSON or SSE
1456
- if (req.method === 'POST' && accept && !acceptsJson && !acceptsEventStream) {
1457
- this.logger.debug('Accept header validation failed for POST, returning 406');
1458
- res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
1459
- error: 'Not Acceptable',
1460
- message: `POST requests must accept ${MIME_TYPES.JSON} or ${MIME_TYPES.EVENT_STREAM}`
1461
- });
1462
- return;
1463
- }
1464
- // Check if this is initialization (no session ID yet)
1465
- const isInitialization = isInitializeRequest(body);
1466
- this.logger.debug('Session validation', { isInitialization, sessionId, bodyMethod: body?.method });
1467
- // Validate session (except for initialization)
1468
- if (!isInitialization && sessionId) {
1469
- const session = profileState.sessions.get(sessionId);
1470
- if (!session) {
1471
- res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
1472
- return;
1473
- }
1474
- if (filteringHeader !== undefined) {
1475
- if (!session.filteringHeader || session.filteringHeader !== filteringHeader) {
1476
- throw new ValidationError('X-Mcp4-Params header mismatch for existing session.');
1477
- }
1478
- }
1479
- if (normalizedToolFilterHeader !== undefined) {
1480
- if (!session.toolFilterHeader || session.toolFilterHeader !== normalizedToolFilterHeader) {
1481
- throw new ValidationError(`X-Mcp4-Tools header mismatch for existing session. Expected: '${session.toolFilterHeader ?? ''}', Got: '${normalizedToolFilterHeader}'.`);
1482
- }
1483
- }
1484
- this.updateSessionActivity(profileState, sessionId);
1485
- }
1486
- else if (!isInitialization && !sessionId) {
1487
- this.logger.debug('Session validation failed: non-init request without sessionId');
1488
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required (except for initialization)' });
1489
- return;
1490
- }
1491
- // Determine message type
1492
- const messageType = this.getMessageType(body);
1493
- this.logger.debug('Message type determined', { messageType, hasMessageHandler: !!this.messageHandler });
1494
- // If only notifications/responses, return 202 Accepted
1495
- if (messageType === 'notification-only' || messageType === 'response-only') {
1496
- if (this.messageHandler) {
1497
- await this.messageHandler(body, undefined, requestProfileId);
1498
- }
1499
- res.status(HTTP_STATUS.ACCEPTED).send();
1500
- return;
1501
- }
1502
- // If contains requests, process and return response
1503
- if (messageType === 'request') {
1504
- if (!this.messageHandler) {
1505
- res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error', message: 'Message handler not configured' });
1506
- return;
1507
- }
1508
- // Create session on initialization
1509
- let newSessionId;
1510
- if (isInitialization) {
1511
- // Extract and validate auth token from headers
1512
- const authInfo = this.extractAuthToken(req, profileState);
1513
- this.logger.debug('Auth token extracted', { authType: authInfo?.type, hasToken: !!authInfo?.token });
1514
- // If OAuth is configured, require authentication for initialization
1515
- // This ensures clients like Cursor properly handle OAuth flow
1516
- if (profileState.oauthProvider && !authInfo.token) {
1517
- this.logger.debug('OAuth configured but no token provided, triggering OAuth flow');
1518
- const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
1519
- res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${profileState.oauthProvider.scopes.join(' ')}"`);
1520
- res.status(HTTP_STATUS.UNAUTHORIZED).json({
1521
- error: 'Unauthorized',
1522
- message: 'Authentication required for OAuth'
1523
- });
1524
- return;
1525
- }
1526
- // Allow initialization without token for non-OAuth scenarios
1527
- // Validate token if auth is configured and token is provided
1528
- if (authInfo && authInfo.token && profileState.context.authConfigs && profileState.context.baseUrl) {
1529
- // Find matching auth config based on priority (authConfigs is sorted)
1530
- // For 'bearer' token type, 'oauth' config is also a match
1531
- const authConfig = profileState.context.authConfigs.find(c => c.type === authInfo.type ||
1532
- (authInfo.type === 'bearer' && c.type === 'oauth'));
1533
- if (authConfig && authConfig.validation_endpoint) {
1534
- this.logger.info('Validating auth token during initialization', {
1535
- authType: authConfig.type, // Use config type for logging
1536
- endpoint: authConfig.validation_endpoint,
1537
- });
1538
- const isValid = await this.validateAuthToken(authConfig, authInfo.token, profileState.context.baseUrl);
1539
- if (!isValid) {
1540
- this.logger.warn('Auth token validation failed during initialization', {
1541
- authType: authInfo.type,
1542
- });
1543
- res.status(HTTP_STATUS.UNAUTHORIZED).json({
1544
- error: 'Unauthorized',
1545
- message: 'Invalid or expired authentication token'
1546
- });
1547
- return;
1548
- }
1549
- this.logger.info('Auth token validation successful');
1550
- }
1551
- }
1552
- // Look up OAuth tokens if this is an OAuth token
1553
- let refreshToken;
1554
- let accessTokenExpiresAt;
1555
- let scopes;
1556
- let oauthClientId;
1557
- if (authInfo.token && (authInfo.type === 'oauth' || authInfo.type === 'bearer')) {
1558
- const tokenData = profileState.oauthTokensByAccessToken.get(authInfo.token);
1559
- if (tokenData) {
1560
- refreshToken = tokenData.refreshToken;
1561
- accessTokenExpiresAt = tokenData.expiresAt;
1562
- scopes = tokenData.scopes;
1563
- oauthClientId = tokenData.clientId;
1564
- this.logger.debug('Found OAuth token data for session', {
1565
- hasRefreshToken: !!refreshToken,
1566
- hasExpiration: !!accessTokenExpiresAt,
1567
- scopesCount: scopes.length,
1568
- });
1569
- }
1570
- else {
1571
- this.logger.debug('No OAuth token data found in map (may be non-OAuth bearer token)', {
1572
- hasToken: true,
1573
- });
1574
- }
1575
- }
1576
- newSessionId = this.createSession(profileState, authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, parsedFiltering?.filtering, parsedFiltering?.normalizedHeader, parsedToolFilter, normalizedToolFilterHeader);
1577
- }
1578
- this.logger.debug('Calling messageHandler', { body, sessionId: isInitialization ? newSessionId : sessionId });
1579
- const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId, requestProfileId);
1580
- this.logger.debug('MessageHandler response', { response });
1581
- // Debug: Check OAuth conditions
1582
- this.logger.debug('Checking OAuth conditions', {
1583
- responseError: response.error,
1584
- hasOAuthProvider: !!profileState.oauthProvider,
1585
- oauthProviderType: typeof profileState.oauthProvider
1586
- });
1587
- // Check if response contains OAuth error and add WWW-Authenticate header
1588
- const responseObj = response;
1589
- if (responseObj.error && responseObj.error.data && responseObj.error.data.oauth_required) {
1590
- const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
1591
- res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="api"`);
1592
- res.status(HTTP_STATUS.UNAUTHORIZED); // Set 401 status for OAuth errors
1593
- }
1594
- // Decide response format based on Accept header
1595
- const accept = req.headers.accept || '';
1596
- const wantsOnlySSE = accept.trim() === MIME_TYPES.EVENT_STREAM;
1597
- if (wantsOnlySSE) {
1598
- // Return SSE response only when client explicitly wants text/event-stream only
1599
- this.logger.debug('Sending SSE response', { response, newSessionId });
1600
- this.startSSEResponse(res, response, newSessionId, sessionId);
1601
- }
1602
- else {
1603
- // Return JSON response (default for requests)
1604
- if (newSessionId) {
1605
- res.setHeader('Mcp-Session-Id', newSessionId);
1606
- }
1607
- // Security: Prevent caching of sensitive API responses
1608
- res.setHeader('Cache-Control', 'no-store');
1609
- this.logger.debug('Sending JSON response', { response, newSessionId });
1610
- res.json(response);
1611
- }
1612
- return;
1613
- }
1614
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Invalid message type' });
1615
- }
1616
- catch (error) {
1617
- const correlationId = generateCorrelationId();
1618
- this.logger.error('POST request error', error, { correlationId });
1619
- res.setHeader('Cache-Control', 'no-store');
1620
- let status = 500;
1621
- let errorLabel = 'Internal Server Error';
1622
- let message = `Internal error (correlation ID: ${correlationId})`;
1623
- if (error instanceof ValidationError) {
1624
- status = HTTP_STATUS.BAD_REQUEST;
1625
- errorLabel = 'Bad Request';
1626
- message = `Validation error: ${error.message} (correlation ID: ${correlationId})`;
1627
- }
1628
- else if (error instanceof AuthenticationError) {
1629
- status = HTTP_STATUS.UNAUTHORIZED;
1630
- errorLabel = 'Unauthorized';
1631
- message = `Authentication failed: ${error.message} (correlation ID: ${correlationId})`;
1632
- }
1633
- else if (error instanceof AuthorizationError) {
1634
- status = HTTP_STATUS.FORBIDDEN;
1635
- errorLabel = 'Forbidden';
1636
- message = `Authorization failed: ${error.message} (correlation ID: ${correlationId})`;
1637
- }
1638
- else if (error instanceof RateLimitError) {
1639
- status = HTTP_STATUS.TOO_MANY_REQUESTS;
1640
- errorLabel = 'Too Many Requests';
1641
- message = `Rate limit exceeded: ${error.message} (correlation ID: ${correlationId})`;
1642
- }
1643
- res.status(status).json({ error: errorLabel, message, correlationId });
1644
- // Record error metrics
1645
- if (this.metrics) {
1646
- const duration = (Date.now() - startTime) / 1000;
1647
- this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1648
- }
1649
- }
1650
- finally {
1651
- // Record success metrics (if not already recorded in catch)
1652
- if (this.metrics && res.statusCode !== 500) {
1653
- const duration = (Date.now() - startTime) / 1000;
1654
- this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
1655
- }
1656
- }
1657
- }
1658
- /**
1659
- * Handle GET requests - Client opening SSE stream for server messages
1660
- *
1661
- * MCP Spec: GET opens SSE stream for server-initiated requests/notifications
1662
- */
1663
- async handleGet(req, res) {
1664
- const startTime = Date.now();
1665
- try {
1666
- const profileState = await this.getProfileStateForRequest(req);
1667
- if (!profileState) {
1668
- this.respondProfileNotFound(res, req.profileId);
1669
- return;
1670
- }
1671
- const sessionId = req.sessionId;
1672
- const lastEventId = req.headers['last-event-id'];
1673
- // Validate Accept header
1674
- const accept = req.headers.accept || '';
1675
- if (!accept.includes(MIME_TYPES.EVENT_STREAM)) {
1676
- res.status(HTTP_STATUS.METHOD_NOT_ALLOWED).json({ error: 'Method Not Allowed', message: `Must accept ${MIME_TYPES.EVENT_STREAM}` });
1677
- return;
1678
- }
1679
- // Validate session
1680
- if (!sessionId) {
1681
- res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
1682
- return;
1683
- }
1684
- const session = profileState.sessions.get(sessionId);
1685
- if (!session) {
1686
- res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
1687
- return;
1688
- }
1689
- this.updateSessionActivity(profileState, sessionId);
1690
- // Start SSE stream
1691
- this.startSSEStream(res, sessionId, lastEventId, profileState);
1692
- // Record metrics for successful SSE start
1693
- if (this.metrics) {
1694
- const duration = (Date.now() - startTime) / 1000;
1695
- this.metrics.recordHttpRequest(req.method, req.path, 200, duration);
1696
- }
1697
- }
1698
- catch (error) {
1699
- const correlationId = generateCorrelationId();
1700
- this.logger.error('GET request error', error, { correlationId });
1701
- const status = 500;
1702
- if (!res.headersSent) {
1703
- res.setHeader('Cache-Control', 'no-store');
1704
- res.status(status).json({
1705
- error: 'Internal Server Error',
1706
- message: `Internal error (correlation ID: ${correlationId})`,
1707
- correlationId
1708
- });
1709
- }
1710
- // Record error metrics
1711
- if (this.metrics) {
1712
- const duration = (Date.now() - startTime) / 1000;
1713
- this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1714
- }
1715
- }
1716
- }
1717
- /**
1718
- * Handle DELETE requests - Client terminating session
1719
- *
1720
- * MCP Spec: DELETE explicitly terminates session
1721
- */
1722
- handleDelete(req, res) {
1723
- const startTime = Date.now();
1724
- const sessionId = req.sessionId;
1725
- const profileId = this.getProfileIdForRequest(req);
1726
- if (!profileId) {
1727
- this.respondProfileNotFound(res, req.profileId);
1728
- return;
1729
- }
1730
- if (!sessionId) {
1731
- const status = 400;
1732
- res.status(status).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
1733
- if (this.metrics) {
1734
- const duration = (Date.now() - startTime) / 1000;
1735
- this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1736
- }
1737
- return;
1738
- }
1739
- const profileState = this.profileStates.get(profileId);
1740
- if (!profileState) {
1741
- this.respondProfileNotFound(res, profileId);
1742
- return;
1743
- }
1744
- const session = profileState.sessions.get(sessionId);
1745
- if (!session) {
1746
- const status = 404;
1747
- res.status(status).json({ error: 'Not Found', message: 'Session not found' });
1748
- if (this.metrics) {
1749
- const duration = (Date.now() - startTime) / 1000;
1750
- this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1751
- }
1752
- return;
1753
- }
1754
- this.destroySession(profileState, sessionId);
1755
- const status = 204;
1756
- res.status(status).send();
1757
- if (this.metrics) {
1758
- const duration = (Date.now() - startTime) / 1000;
1759
- this.metrics.recordHttpRequest(req.method, req.path, status, duration);
1760
- }
1761
- }
1762
- /**
1763
- * Start SSE response for a POST request
1764
- *
1765
- * Why: Returns response via SSE stream, allows server-initiated messages
1766
- */
1767
- startSSEResponse(res, response, newSessionId, sessionId) {
1768
- res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
1769
- res.setHeader('Cache-Control', 'no-cache');
1770
- res.setHeader('Connection', 'keep-alive');
1771
- if (newSessionId) {
1772
- res.setHeader('Mcp-Session-Id', newSessionId);
1773
- }
1774
- // Send response
1775
- const eventId = Date.now();
1776
- res.write(`id: ${eventId}\n`);
1777
- res.write(`data: ${JSON.stringify(response)}\n\n`);
1778
- // Close stream
1779
- res.end();
1780
- }
1781
- /**
1782
- * Start SSE stream for GET request
1783
- *
1784
- * Why: Allows server to send requests/notifications to client
1785
- */
1786
- startSSEStream(res, sessionId, lastEventId, profileState) {
1787
- res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
1788
- res.setHeader('Cache-Control', 'no-cache');
1789
- res.setHeader('Connection', 'keep-alive');
1790
- const streamId = crypto.randomBytes(16).toString('hex');
1791
- const session = profileState.sessions.get(sessionId);
1792
- const streamState = {
1793
- streamId,
1794
- lastEventId: lastEventId ? parseInt(lastEventId, 10) : 0,
1795
- messageQueue: [],
1796
- active: true,
1797
- response: res,
1798
- };
1799
- session.sseStreams.set(streamId, streamState);
1800
- // Replay missed messages if resuming
1801
- if (lastEventId) {
1802
- this.replayMessages(res, streamState);
1803
- }
1804
- // Setup heartbeat if enabled
1805
- let heartbeatInterval = null;
1806
- if (this.config.heartbeatEnabled) {
1807
- heartbeatInterval = setInterval(() => {
1808
- if (streamState.active) {
1809
- res.write(':ping\n\n');
1810
- }
1811
- }, this.config.heartbeatIntervalMs);
1812
- }
1813
- // Handle client disconnect
1814
- res.on('close', () => {
1815
- streamState.active = false;
1816
- if (heartbeatInterval) {
1817
- clearInterval(heartbeatInterval);
1818
- }
1819
- this.logger.info('SSE stream closed', { sessionId, streamId });
1820
- });
1821
- this.logger.info('SSE stream opened', { sessionId, streamId, resuming: !!lastEventId });
1822
- }
1823
- /**
1824
- * Replay messages after Last-Event-ID
1825
- *
1826
- * Why: Resumability - client can reconnect and receive missed messages
1827
- */
1828
- replayMessages(res, streamState) {
1829
- const missedMessages = streamState.messageQueue.filter(msg => msg.eventId > streamState.lastEventId);
1830
- for (const msg of missedMessages) {
1831
- res.write(`id: ${msg.eventId}\n`);
1832
- res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
1833
- }
1834
- this.logger.info('Replayed messages', { count: missedMessages.length, streamId: streamState.streamId });
1835
- }
1836
- /**
1837
- * Send message to client via SSE
1838
- *
1839
- * Why: Server-initiated requests/notifications
1840
- */
1841
- sendToClient(profileId, sessionId, message) {
1842
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
1843
- if (!session) {
1844
- this.logger.warn('Cannot send to client: session not found', { profileId, sessionId });
1845
- return;
1846
- }
1847
- const eventId = Date.now();
1848
- const queuedMessage = {
1849
- eventId,
1850
- data: message,
1851
- timestamp: Date.now(),
1852
- };
1853
- // Send to all active streams for this session
1854
- for (const [streamId, streamState] of session.sseStreams) {
1855
- if (streamState.active) {
1856
- // Queue for resumability
1857
- streamState.messageQueue.push(queuedMessage);
1858
- // Keep only last 100 messages
1859
- if (streamState.messageQueue.length > 100) {
1860
- streamState.messageQueue.shift();
1861
- }
1862
- }
1863
- }
1864
- }
1865
- /**
1866
- * Determine message type (request, notification, response)
1867
- */
1868
- getMessageType(body) {
1869
- if (Array.isArray(body)) {
1870
- // Batch
1871
- const hasRequest = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && 'id' in msg);
1872
- const hasNotification = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && !('id' in msg));
1873
- const hasResponse = body.some((msg) => typeof msg === 'object' && msg !== null && ('result' in msg || 'error' in msg));
1874
- if (hasRequest)
1875
- return 'request';
1876
- if (hasNotification && !hasResponse)
1877
- return 'notification-only';
1878
- if (hasResponse && !hasNotification)
1879
- return 'response-only';
1880
- return 'mixed';
1881
- }
1882
- else if (typeof body === 'object' && body !== null) {
1883
- const msg = body;
1884
- if ('method' in msg) {
1885
- return 'id' in msg ? 'request' : 'notification-only';
1886
- }
1887
- if ('result' in msg || 'error' in msg) {
1888
- return 'response-only';
1889
- }
1890
- }
1891
- return 'unknown';
1892
- }
1893
- getFilteringHeaderValue(req) {
1894
- const headerValue = req.headers['x-mcp4-params'];
1895
- if (Array.isArray(headerValue)) {
1896
- if (headerValue.length === 0) {
1897
- return undefined;
1898
- }
1899
- if (headerValue.length > 1) {
1900
- throw new ValidationError('Invalid X-Mcp4-Params header. Expected comma-separated key=value pairs.');
1901
- }
1902
- return headerValue[0];
1903
- }
1904
- return headerValue;
1905
- }
1906
- getToolFilterHeaderValue(req) {
1907
- const headerValue = req.headers['x-mcp4-tools'];
1908
- if (Array.isArray(headerValue)) {
1909
- if (headerValue.length === 0) {
1910
- return undefined;
1911
- }
1912
- if (headerValue.length > 1) {
1913
- throw new ValidationError('Invalid X-Mcp4-Tools header. Expected comma-separated tool names.');
1914
- }
1915
- return headerValue[0];
1916
- }
1917
- return headerValue;
1918
- }
1919
- /**
1920
- * Create new session
1921
- *
1922
- * Why: Stateful sessions for MCP protocol
1923
- */
1924
- createSession(profileState, authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, filtering, filteringHeader, toolFilterRequest, toolFilterHeader) {
1925
- // Validate token if provided (defense in depth)
1926
- if (authToken) {
1927
- this.validateToken(authToken, 'Session auth token');
1928
- }
1929
- const sessionId = crypto.randomUUID();
1930
- const session = {
1931
- id: sessionId,
1932
- createdAt: Date.now(),
1933
- lastActivityAt: Date.now(),
1934
- sseStreams: new Map(),
1935
- authToken,
1936
- refreshToken,
1937
- accessTokenExpiresAt,
1938
- scopes,
1939
- oauthClientId,
1940
- filtering,
1941
- filteringHeader,
1942
- toolFilterRequest,
1943
- toolFilterHeader,
1944
- };
1945
- profileState.sessions.set(sessionId, session);
1946
- this.logger.info('Session created', {
1947
- profileId: profileState.profileId,
1948
- sessionId,
1949
- hasAuthToken: !!authToken,
1950
- hasRefreshToken: !!refreshToken,
1951
- hasExpiration: !!accessTokenExpiresAt,
1952
- });
1953
- // Record metrics
1954
- if (this.metrics) {
1955
- this.metrics.recordSessionCreated();
1956
- }
1957
- return sessionId;
1958
- }
1959
- /**
1960
- * Update session activity timestamp
1961
- */
1962
- updateSessionActivity(profileState, sessionId) {
1963
- const session = profileState.sessions.get(sessionId);
1964
- if (session) {
1965
- session.lastActivityAt = Date.now();
1966
- }
1967
- }
1968
- /**
1969
- * Destroy session and cleanup resources
1970
- *
1971
- * Why: Free memory, close streams
1972
- */
1973
- destroySession(profileState, sessionId) {
1974
- const session = profileState.sessions.get(sessionId);
1975
- if (session) {
1976
- // Close all active SSE streams
1977
- for (const [, streamState] of session.sseStreams) {
1978
- streamState.active = false;
1979
- // Close the HTTP response to terminate the SSE connection
1980
- try {
1981
- if (!streamState.response.headersSent || !streamState.response.writableEnded) {
1982
- streamState.response.end();
1983
- }
1984
- }
1985
- catch (error) {
1986
- // Ignore errors if response is already closed
1987
- this.logger.debug('Failed to close SSE response', { error: error.message });
1988
- }
1989
- }
1990
- session.sseStreams.clear();
1991
- // Clean up OAuth token from map if present
1992
- if (session.authToken) {
1993
- profileState.oauthTokensByAccessToken.delete(session.authToken);
1994
- }
1995
- profileState.sessions.delete(sessionId);
1996
- this.logger.info('Session destroyed', { profileId: profileState.profileId, sessionId });
1997
- // Notify session destruction listeners (for cleanup in MCPServer)
1998
- this.notifySessionDestroyed(profileState.profileId, sessionId);
1999
- // Record metrics
2000
- if (this.metrics) {
2001
- this.metrics.recordSessionDestroyed();
2002
- this.metrics.clearToolsSession(sessionId);
2003
- }
2004
- }
2005
- }
2006
- /**
2007
- * Register listener for session destruction events
2008
- *
2009
- * Why: Allows MCPServer to cleanup per-session HTTP clients
2010
- */
2011
- onSessionDestroyed(listener) {
2012
- this.sessionDestroyedListeners.push(listener);
2013
- }
2014
- /**
2015
- * Notify all listeners about session destruction
2016
- */
2017
- notifySessionDestroyed(profileId, sessionId) {
2018
- for (const listener of this.sessionDestroyedListeners) {
2019
- try {
2020
- listener(profileId, sessionId);
2021
- }
2022
- catch (error) {
2023
- this.logger.error('Session destroyed listener error', error);
2024
- }
2025
- }
2026
- }
2027
- /**
2028
- * Store OAuth tokens in internal map for later session initialization
2029
- *
2030
- * Why: Bridge between /oauth/token endpoint (where we see OAuthTokens)
2031
- * and session initialization (where we only see access token in Authorization header)
2032
- */
2033
- storeOAuthTokens(profileState, tokens, clientId, scopes) {
2034
- if (!tokens.access_token) {
2035
- this.logger.warn('OAuth tokens missing access_token, skipping storage');
2036
- return;
2037
- }
2038
- const expiresAt = tokens.expires_in
2039
- ? Date.now() + tokens.expires_in * 1000
2040
- : undefined;
2041
- profileState.oauthTokensByAccessToken.set(tokens.access_token, {
2042
- refreshToken: tokens.refresh_token,
2043
- expiresAt,
2044
- clientId,
2045
- scopes,
2046
- });
2047
- this.logger.debug('Stored OAuth tokens', {
2048
- profileId: profileState.profileId,
2049
- hasRefreshToken: !!tokens.refresh_token,
2050
- expiresAt,
2051
- clientId,
2052
- scopesCount: scopes.length,
2053
- });
2054
- }
2055
- /**
2056
- * Cleanup expired sessions
2057
- *
2058
- * Why: Prevent memory leaks, enforce session timeout
2059
- *
2060
- * OAuth sessions with refresh tokens have extended or unlimited timeout
2061
- * to avoid forcing users to re-authenticate after periods of inactivity
2062
- */
2063
- cleanupExpiredSessions() {
2064
- const now = Date.now();
2065
- const expiredSessions = [];
2066
- // Default OAuth session timeout: 24 hours (or configurable)
2067
- const oauthSessionTimeoutMs = this.config.oauthSessionTimeoutMs
2068
- ?? (24 * 60 * 60 * 1000); // 24 hours default
2069
- for (const profileState of this.profileStates.values()) {
2070
- // Cleanup OAuth provider resources (states, codes, tokens)
2071
- if (profileState.oauthProvider) {
2072
- profileState.oauthProvider.cleanup();
2073
- }
2074
- for (const [sessionId, session] of profileState.sessions) {
2075
- const age = now - session.lastActivityAt;
2076
- // OAuth sessions with refresh tokens: use extended timeout or never expire
2077
- if (session.refreshToken) {
2078
- // If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
2079
- if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
2080
- expiredSessions.push({ profileId: profileState.profileId, sessionId });
2081
- }
2082
- }
2083
- else {
2084
- // Non-OAuth sessions: use standard timeout
2085
- if (age > this.config.sessionTimeoutMs) {
2086
- expiredSessions.push({ profileId: profileState.profileId, sessionId });
2087
- }
2088
- }
2089
- }
2090
- }
2091
- for (const entry of expiredSessions) {
2092
- const state = this.profileStates.get(entry.profileId);
2093
- if (state) {
2094
- this.destroySession(state, entry.sessionId);
2095
- }
2096
- }
2097
- if (expiredSessions.length > 0) {
2098
- this.logger.info('Cleaned up expired sessions', { count: expiredSessions.length });
2099
- }
2100
- }
2101
- /**
2102
- * Get auth token from session
2103
- *
2104
- * Why public: Allows MCPServer to securely access session tokens without breaking encapsulation
2105
- */
2106
- getSessionToken(profileId, sessionId) {
2107
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2108
- return session?.authToken;
2109
- }
2110
- getSessionFiltering(profileId, sessionId) {
2111
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2112
- return session?.filtering;
2113
- }
2114
- getSessionFilteringHeader(profileId, sessionId) {
2115
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2116
- return session?.filteringHeader;
2117
- }
2118
- getSessionToolFilterRequest(profileId, sessionId) {
2119
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2120
- return session?.toolFilterRequest;
2121
- }
2122
- getSessionToolFilter(profileId, sessionId) {
2123
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2124
- return session?.toolFilter;
2125
- }
2126
- getSessionToolFilterHeader(profileId, sessionId) {
2127
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2128
- return session?.toolFilterHeader;
2129
- }
2130
- setSessionToolFilter(profileId, sessionId, toolFilter) {
2131
- const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
2132
- if (!session) {
2133
- return;
2134
- }
2135
- session.toolFilter = toolFilter;
2136
- }
2137
- recordGlobalToolFilterMetrics(summary) {
2138
- if (!this.metrics) {
2139
- return;
2140
- }
2141
- this.metrics.recordToolsTotal('profile', summary.originalCount);
2142
- this.metrics.recordToolsFiltered('global_env', 'allowed', summary.allowedCount);
2143
- this.metrics.recordToolsFiltered('global_env', 'denied', summary.removedCount);
2144
- for (const [type, count] of Object.entries(summary.patternCounts)) {
2145
- this.metrics.recordToolFilterPatternCount(type, count);
2146
- }
2147
- }
2148
- recordSessionToolFilterMetrics(sessionId, allowedCount, request) {
2149
- if (!this.metrics) {
2150
- return;
2151
- }
2152
- this.metrics.recordToolsSession(sessionId, allowedCount);
2153
- this.metrics.recordToolFilterPatternCount('session_allow_list', request.exactNames.size);
2154
- this.metrics.recordToolFilterPatternCount('session_allow_regex', request.regexPatterns.length);
2155
- }
2156
- recordToolFilterRejection(tool, source) {
2157
- if (!this.metrics) {
2158
- return;
2159
- }
2160
- this.metrics.recordToolFilterRejection(tool, source);
2161
- }
2162
- /**
2163
- * Ensure session has a valid access token, refreshing if necessary
2164
- *
2165
- * Why: Transparently refresh expired OAuth tokens before making API calls
2166
- * Returns true if token is valid (or was successfully refreshed), false otherwise
2167
- */
2168
- async ensureValidSessionToken(profileId, sessionId) {
2169
- const profileState = this.profileStates.get(profileId);
2170
- const session = profileState?.sessions.get(sessionId);
2171
- if (!session) {
2172
- return false;
2173
- }
2174
- // If no expiration info, assume token is valid (non-OAuth scenarios)
2175
- if (!session.accessTokenExpiresAt) {
2176
- return true;
2177
- }
2178
- const now = Date.now();
2179
- const refreshThresholdMs = this.config.oauthRefreshThresholdMs ?? (60 * 1000); // Default: 60 seconds before expiration
2180
- const timeUntilExpiration = session.accessTokenExpiresAt - now;
2181
- // If token is expired or about to expire, refresh it
2182
- if (timeUntilExpiration <= refreshThresholdMs) {
2183
- this.logger.debug('Access token expired or expiring soon, refreshing', {
2184
- profileId,
2185
- sessionId,
2186
- expiresAt: new Date(session.accessTokenExpiresAt).toISOString(),
2187
- timeUntilExpiration,
2188
- });
2189
- return await this.refreshAccessToken(profileId, sessionId);
2190
- }
2191
- return true;
2192
- }
2193
- /**
2194
- * Refresh access token using refresh token
2195
- *
2196
- * Why: Automatically renew expired OAuth access tokens without user intervention
2197
- * Returns true on success, false on failure
2198
- */
2199
- async refreshAccessToken(profileId, sessionId) {
2200
- const profileState = this.profileStates.get(profileId);
2201
- const session = profileState?.sessions.get(sessionId);
2202
- if (!profileState || !session || !session.refreshToken || !profileState.oauthProvider) {
2203
- this.logger.warn('Cannot refresh token: missing session, refreshToken, or OAuth provider', {
2204
- profileId,
2205
- sessionId,
2206
- hasSession: !!session,
2207
- hasRefreshToken: !!session?.refreshToken,
2208
- hasOAuthProvider: !!profileState?.oauthProvider,
2209
- });
2210
- return false;
2211
- }
2212
- try {
2213
- // Get client from OAuth provider
2214
- // Try to find client by clientId stored in session, or use default client
2215
- let client;
2216
- if (session.oauthClientId) {
2217
- await profileState.oauthProvider.ensureEndpointsInitialized();
2218
- client = await profileState.oauthProvider.clientsStore.getClient(session.oauthClientId);
2219
- }
2220
- // Fallback to default client from config if session client not found
2221
- if (!client && profileState.oauthProvider) {
2222
- await profileState.oauthProvider.ensureEndpointsInitialized();
2223
- // Try common client IDs
2224
- const defaultClientIds = ['mcp-proxy-client'];
2225
- if (profileState.context.oauthConfig?.client_id) {
2226
- defaultClientIds.unshift(profileState.context.oauthConfig.client_id);
2227
- }
2228
- for (const clientId of defaultClientIds) {
2229
- client = await profileState.oauthProvider.clientsStore.getClient(clientId);
2230
- if (client)
2231
- break;
2232
- }
2233
- }
2234
- if (!client) {
2235
- this.logger.error('Cannot refresh token: OAuth client not found', undefined, {
2236
- profileId,
2237
- sessionId,
2238
- oauthClientId: session.oauthClientId,
2239
- });
2240
- return false;
2241
- }
2242
- // Exchange refresh token for new tokens
2243
- const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, session.refreshToken, session.scopes);
2244
- // Update session with new tokens
2245
- const oldAccessToken = session.authToken;
2246
- session.authToken = tokens.access_token;
2247
- session.refreshToken = tokens.refresh_token || session.refreshToken; // Keep old refresh token if new one not provided
2248
- session.accessTokenExpiresAt = tokens.expires_in
2249
- ? Date.now() + tokens.expires_in * 1000
2250
- : undefined;
2251
- // Update token map: remove old token, add new one
2252
- if (oldAccessToken) {
2253
- profileState.oauthTokensByAccessToken.delete(oldAccessToken);
2254
- }
2255
- this.storeOAuthTokens(profileState, tokens, client.client_id, session.scopes || []);
2256
- this.logger.info('Access token refreshed successfully', {
2257
- profileId,
2258
- sessionId,
2259
- newExpiresAt: session.accessTokenExpiresAt ? new Date(session.accessTokenExpiresAt).toISOString() : undefined,
2260
- });
2261
- return true;
2262
- }
2263
- catch (error) {
2264
- this.logger.error('Token refresh failed', error instanceof Error ? error : new Error(String(error)), {
2265
- profileId,
2266
- sessionId,
2267
- });
2268
- return false;
2269
- }
2270
- }
2271
- /**
2272
- * Set message handler for processing incoming JSON-RPC messages
2273
- */
2274
- setMessageHandler(handler) {
2275
- this.messageHandler = handler;
2276
- }
2277
- /**
2278
- * Check if OAuth provider is configured
2279
- */
2280
- hasOAuthProvider(profileId) {
2281
- if (!profileId) {
2282
- const defaultProfileId = this.getDefaultProfileId();
2283
- if (!defaultProfileId) {
2284
- return false;
2285
- }
2286
- const state = this.profileStates.get(defaultProfileId);
2287
- return state ? state.oauthProvider !== null : false;
2288
- }
2289
- const state = this.profileStates.get(profileId);
2290
- return state ? state.oauthProvider !== null : false;
2291
- }
2292
- /**
2293
- * Get server URL
2294
- */
2295
- getServerUrl(profileId) {
2296
- return this.getServerOrigin(profileId);
2297
- }
2298
- /**
2299
- * Get OAuth authorization URL
2300
- */
2301
- getOAuthAuthorizationUrl(profileId) {
2302
- if (!profileId) {
2303
- const defaultProfileId = this.getDefaultProfileId();
2304
- if (!defaultProfileId) {
2305
- return '';
2306
- }
2307
- return this.profileStates.get(defaultProfileId)?.oauthProvider?.authorizationEndpoint || '';
2308
- }
2309
- return this.profileStates.get(profileId)?.oauthProvider?.authorizationEndpoint || '';
2310
- }
2311
- /**
2312
- * Get OAuth scopes
2313
- */
2314
- getOAuthScopes(profileId) {
2315
- if (!profileId) {
2316
- const defaultProfileId = this.getDefaultProfileId();
2317
- if (!defaultProfileId) {
2318
- return [];
2319
- }
2320
- return this.profileStates.get(defaultProfileId)?.oauthProvider?.scopes || [];
2321
- }
2322
- return this.profileStates.get(profileId)?.oauthProvider?.scopes || [];
2323
- }
2324
- /**
2325
- * Start HTTP server
2326
- */
2327
- async start() {
2328
- return new Promise((resolve, reject) => {
2329
- try {
2330
- // Check for SSL configuration from environment variables
2331
- const sslCertFile = process.env.MCP4_SSL_CERT_FILE;
2332
- const sslKeyFile = process.env.MCP4_SSL_KEY_FILE;
2333
- if (sslCertFile && sslKeyFile) {
2334
- // Start HTTPS server
2335
- this.logger.info('SSL configuration detected, starting HTTPS server', {
2336
- certFile: sslCertFile,
2337
- keyFile: sslKeyFile,
2338
- });
2339
- try {
2340
- const httpsOptions = {
2341
- cert: fs.readFileSync(sslCertFile),
2342
- key: fs.readFileSync(sslKeyFile),
2343
- };
2344
- this.server = https.createServer(httpsOptions, this.app);
2345
- this.server.listen(this.config.port, this.config.host, () => {
2346
- this.logger.info('HTTPS transport started', {
2347
- host: this.config.host,
2348
- port: this.config.port,
2349
- heartbeat: this.config.heartbeatEnabled,
2350
- metrics: this.config.metricsEnabled,
2351
- });
2352
- // Start session cleanup interval
2353
- this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
2354
- resolve();
2355
- });
2356
- }
2357
- catch (sslError) {
2358
- this.logger.error('Failed to start HTTPS server', sslError instanceof Error ? sslError : new Error(String(sslError)));
2359
- reject(sslError);
2360
- return;
2361
- }
2362
- }
2363
- else {
2364
- // Start HTTP server
2365
- this.server = this.app.listen(this.config.port, this.config.host, () => {
2366
- this.logger.info('HTTP transport started', {
2367
- host: this.config.host,
2368
- port: this.config.port,
2369
- heartbeat: this.config.heartbeatEnabled,
2370
- metrics: this.config.metricsEnabled,
2371
- });
2372
- // Start session cleanup interval
2373
- this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
2374
- resolve();
2375
- });
2376
- }
2377
- this.server.on('error', reject);
2378
- }
2379
- catch (error) {
2380
- reject(error);
2381
- }
2382
- });
2383
- }
2384
- /**
2385
- * Stop HTTP server
2386
- */
2387
- async stop() {
2388
- if (this.cleanupInterval) {
2389
- clearInterval(this.cleanupInterval);
2390
- this.cleanupInterval = null;
2391
- }
2392
- // Destroy all sessions
2393
- for (const profileState of this.profileStates.values()) {
2394
- for (const sessionId of profileState.sessions.keys()) {
2395
- this.destroySession(profileState, sessionId);
2396
- }
2397
- }
2398
- if (this.server) {
2399
- return new Promise((resolve, reject) => {
2400
- this.server.close((err) => {
2401
- if (err)
2402
- reject(err);
2403
- else {
2404
- this.logger.info('HTTP transport stopped');
2405
- resolve();
2406
- }
2407
- });
2408
- });
2409
- }
2410
- }
2411
- }
2412
- //# sourceMappingURL=http-transport.js.map