mcp4openapi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +489 -0
  3. package/dist/composite-executor.d.ts +65 -0
  4. package/dist/composite-executor.d.ts.map +1 -0
  5. package/dist/composite-executor.js +147 -0
  6. package/dist/composite-executor.js.map +1 -0
  7. package/dist/constants.d.ts +36 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +36 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/http-transport.d.ts +195 -0
  12. package/dist/http-transport.d.ts.map +1 -0
  13. package/dist/http-transport.js +760 -0
  14. package/dist/http-transport.js.map +1 -0
  15. package/dist/interceptors.d.ts +74 -0
  16. package/dist/interceptors.d.ts.map +1 -0
  17. package/dist/interceptors.js +220 -0
  18. package/dist/interceptors.js.map +1 -0
  19. package/dist/logger.d.ts +81 -0
  20. package/dist/logger.d.ts.map +1 -0
  21. package/dist/logger.js +264 -0
  22. package/dist/logger.js.map +1 -0
  23. package/dist/mcp-server.d.ts +110 -0
  24. package/dist/mcp-server.d.ts.map +1 -0
  25. package/dist/mcp-server.js +568 -0
  26. package/dist/mcp-server.js.map +1 -0
  27. package/dist/metrics.d.ts +86 -0
  28. package/dist/metrics.d.ts.map +1 -0
  29. package/dist/metrics.js +229 -0
  30. package/dist/metrics.js.map +1 -0
  31. package/dist/openapi-parser.d.ts +35 -0
  32. package/dist/openapi-parser.d.ts.map +1 -0
  33. package/dist/openapi-parser.js +160 -0
  34. package/dist/openapi-parser.js.map +1 -0
  35. package/dist/profile-loader.d.ts +25 -0
  36. package/dist/profile-loader.d.ts.map +1 -0
  37. package/dist/profile-loader.js +134 -0
  38. package/dist/profile-loader.js.map +1 -0
  39. package/dist/schema-validator.d.ts +32 -0
  40. package/dist/schema-validator.d.ts.map +1 -0
  41. package/dist/schema-validator.js +126 -0
  42. package/dist/schema-validator.js.map +1 -0
  43. package/dist/scripts/validate-profile.d.ts +9 -0
  44. package/dist/scripts/validate-profile.d.ts.map +1 -0
  45. package/dist/scripts/validate-profile.js +289 -0
  46. package/dist/scripts/validate-profile.js.map +1 -0
  47. package/dist/scripts/validate-schema.d.ts +9 -0
  48. package/dist/scripts/validate-schema.d.ts.map +1 -0
  49. package/dist/scripts/validate-schema.js +84 -0
  50. package/dist/scripts/validate-schema.js.map +1 -0
  51. package/dist/src/composite-executor.d.ts +75 -0
  52. package/dist/src/composite-executor.d.ts.map +1 -0
  53. package/dist/src/composite-executor.js +175 -0
  54. package/dist/src/composite-executor.js.map +1 -0
  55. package/dist/src/constants.d.ts +36 -0
  56. package/dist/src/constants.d.ts.map +1 -0
  57. package/dist/src/constants.js +36 -0
  58. package/dist/src/constants.js.map +1 -0
  59. package/dist/src/dag-executor.d.ts +49 -0
  60. package/dist/src/dag-executor.d.ts.map +1 -0
  61. package/dist/src/dag-executor.js +138 -0
  62. package/dist/src/dag-executor.js.map +1 -0
  63. package/dist/src/errors.d.ts +47 -0
  64. package/dist/src/errors.d.ts.map +1 -0
  65. package/dist/src/errors.js +99 -0
  66. package/dist/src/errors.js.map +1 -0
  67. package/dist/src/generated-schemas.d.ts +661 -0
  68. package/dist/src/generated-schemas.d.ts.map +1 -0
  69. package/dist/src/generated-schemas.js +66 -0
  70. package/dist/src/generated-schemas.js.map +1 -0
  71. package/dist/src/http-client-factory.d.ts +62 -0
  72. package/dist/src/http-client-factory.d.ts.map +1 -0
  73. package/dist/src/http-client-factory.js +121 -0
  74. package/dist/src/http-client-factory.js.map +1 -0
  75. package/dist/src/http-transport.d.ts +194 -0
  76. package/dist/src/http-transport.d.ts.map +1 -0
  77. package/dist/src/http-transport.js +851 -0
  78. package/dist/src/http-transport.js.map +1 -0
  79. package/dist/src/index.d.ts +8 -0
  80. package/dist/src/index.d.ts.map +1 -0
  81. package/dist/src/index.js +59 -0
  82. package/dist/src/index.js.map +1 -0
  83. package/dist/src/interceptors.d.ts +78 -0
  84. package/dist/src/interceptors.d.ts.map +1 -0
  85. package/dist/src/interceptors.js +252 -0
  86. package/dist/src/interceptors.js.map +1 -0
  87. package/dist/src/jsonrpc-validator.d.ts +27 -0
  88. package/dist/src/jsonrpc-validator.d.ts.map +1 -0
  89. package/dist/src/jsonrpc-validator.js +58 -0
  90. package/dist/src/jsonrpc-validator.js.map +1 -0
  91. package/dist/src/lib.d.ts +8 -0
  92. package/dist/src/lib.d.ts.map +1 -0
  93. package/dist/src/lib.js +7 -0
  94. package/dist/src/lib.js.map +1 -0
  95. package/dist/src/logger.d.ts +81 -0
  96. package/dist/src/logger.d.ts.map +1 -0
  97. package/dist/src/logger.js +264 -0
  98. package/dist/src/logger.js.map +1 -0
  99. package/dist/src/mcp-server.d.ts +117 -0
  100. package/dist/src/mcp-server.d.ts.map +1 -0
  101. package/dist/src/mcp-server.js +621 -0
  102. package/dist/src/mcp-server.js.map +1 -0
  103. package/dist/src/metrics.d.ts +86 -0
  104. package/dist/src/metrics.d.ts.map +1 -0
  105. package/dist/src/metrics.js +229 -0
  106. package/dist/src/metrics.js.map +1 -0
  107. package/dist/src/naming-warnings.d.ts +23 -0
  108. package/dist/src/naming-warnings.d.ts.map +1 -0
  109. package/dist/src/naming-warnings.js +83 -0
  110. package/dist/src/naming-warnings.js.map +1 -0
  111. package/dist/src/naming.d.ts +58 -0
  112. package/dist/src/naming.d.ts.map +1 -0
  113. package/dist/src/naming.js +510 -0
  114. package/dist/src/naming.js.map +1 -0
  115. package/dist/src/openapi-parser.d.ts +49 -0
  116. package/dist/src/openapi-parser.d.ts.map +1 -0
  117. package/dist/src/openapi-parser.js +216 -0
  118. package/dist/src/openapi-parser.js.map +1 -0
  119. package/dist/src/profile-loader.d.ts +77 -0
  120. package/dist/src/profile-loader.d.ts.map +1 -0
  121. package/dist/src/profile-loader.js +443 -0
  122. package/dist/src/profile-loader.js.map +1 -0
  123. package/dist/src/schema-validator.d.ts +30 -0
  124. package/dist/src/schema-validator.d.ts.map +1 -0
  125. package/dist/src/schema-validator.js +115 -0
  126. package/dist/src/schema-validator.js.map +1 -0
  127. package/dist/src/testing/fixtures.d.ts +268 -0
  128. package/dist/src/testing/fixtures.d.ts.map +1 -0
  129. package/dist/src/testing/fixtures.js +210 -0
  130. package/dist/src/testing/fixtures.js.map +1 -0
  131. package/dist/src/testing/mock-gitlab-server.d.ts +34 -0
  132. package/dist/src/testing/mock-gitlab-server.d.ts.map +1 -0
  133. package/dist/src/testing/mock-gitlab-server.js +351 -0
  134. package/dist/src/testing/mock-gitlab-server.js.map +1 -0
  135. package/dist/src/testing/mock-utils.d.ts +41 -0
  136. package/dist/src/testing/mock-utils.d.ts.map +1 -0
  137. package/dist/src/testing/mock-utils.js +59 -0
  138. package/dist/src/testing/mock-utils.js.map +1 -0
  139. package/dist/src/testing/test-http-utils.d.ts +52 -0
  140. package/dist/src/testing/test-http-utils.d.ts.map +1 -0
  141. package/dist/src/testing/test-http-utils.js +109 -0
  142. package/dist/src/testing/test-http-utils.js.map +1 -0
  143. package/dist/src/testing/test-types.d.ts +76 -0
  144. package/dist/src/testing/test-types.d.ts.map +1 -0
  145. package/dist/src/testing/test-types.js +7 -0
  146. package/dist/src/testing/test-types.js.map +1 -0
  147. package/dist/src/tool-generator.d.ts +43 -0
  148. package/dist/src/tool-generator.d.ts.map +1 -0
  149. package/dist/src/tool-generator.js +123 -0
  150. package/dist/src/tool-generator.js.map +1 -0
  151. package/dist/src/types/http-transport.d.ts +45 -0
  152. package/dist/src/types/http-transport.d.ts.map +1 -0
  153. package/dist/src/types/http-transport.js +8 -0
  154. package/dist/src/types/http-transport.js.map +1 -0
  155. package/dist/src/types/openapi.d.ts +50 -0
  156. package/dist/src/types/openapi.d.ts.map +1 -0
  157. package/dist/src/types/openapi.js +9 -0
  158. package/dist/src/types/openapi.js.map +1 -0
  159. package/dist/src/types/profile.d.ts +80 -0
  160. package/dist/src/types/profile.d.ts.map +1 -0
  161. package/dist/src/types/profile.js +9 -0
  162. package/dist/src/types/profile.js.map +1 -0
  163. package/dist/src/validation-utils.d.ts +15 -0
  164. package/dist/src/validation-utils.d.ts.map +1 -0
  165. package/dist/src/validation-utils.js +25 -0
  166. package/dist/src/validation-utils.js.map +1 -0
  167. package/dist/testing/fixtures.d.ts +186 -0
  168. package/dist/testing/fixtures.d.ts.map +1 -0
  169. package/dist/testing/fixtures.js +135 -0
  170. package/dist/testing/fixtures.js.map +1 -0
  171. package/dist/testing/http-integration.test.d.ts +7 -0
  172. package/dist/testing/http-integration.test.d.ts.map +1 -0
  173. package/dist/testing/http-integration.test.js +383 -0
  174. package/dist/testing/http-integration.test.js.map +1 -0
  175. package/dist/testing/http-multiuser.test.d.ts +10 -0
  176. package/dist/testing/http-multiuser.test.d.ts.map +1 -0
  177. package/dist/testing/http-multiuser.test.js +255 -0
  178. package/dist/testing/http-multiuser.test.js.map +1 -0
  179. package/dist/testing/integration.test.d.ts +8 -0
  180. package/dist/testing/integration.test.d.ts.map +1 -0
  181. package/dist/testing/integration.test.js +247 -0
  182. package/dist/testing/integration.test.js.map +1 -0
  183. package/dist/testing/mock-gitlab-server.d.ts +34 -0
  184. package/dist/testing/mock-gitlab-server.d.ts.map +1 -0
  185. package/dist/testing/mock-gitlab-server.js +224 -0
  186. package/dist/testing/mock-gitlab-server.js.map +1 -0
  187. package/dist/testing/test-types.d.ts +59 -0
  188. package/dist/testing/test-types.d.ts.map +1 -0
  189. package/dist/testing/test-types.js +7 -0
  190. package/dist/testing/test-types.js.map +1 -0
  191. package/dist/tool-generator.d.ts +43 -0
  192. package/dist/tool-generator.d.ts.map +1 -0
  193. package/dist/tool-generator.js +123 -0
  194. package/dist/tool-generator.js.map +1 -0
  195. package/dist/tsconfig.tsbuildinfo +1 -0
  196. package/dist/types/http-transport.d.ts +39 -0
  197. package/dist/types/http-transport.d.ts.map +1 -0
  198. package/dist/types/http-transport.js +8 -0
  199. package/dist/types/http-transport.js.map +1 -0
  200. package/dist/types/openapi.d.ts +50 -0
  201. package/dist/types/openapi.d.ts.map +1 -0
  202. package/dist/types/openapi.js +9 -0
  203. package/dist/types/openapi.js.map +1 -0
  204. package/dist/types/profile.d.ts +76 -0
  205. package/dist/types/profile.d.ts.map +1 -0
  206. package/dist/types/profile.js +9 -0
  207. package/dist/types/profile.js.map +1 -0
  208. package/package.json +84 -0
  209. package/profile-schema.json +369 -0
@@ -0,0 +1,851 @@
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 crypto from 'crypto';
12
+ import rateLimit from 'express-rate-limit';
13
+ import { isInitializeRequest } from './jsonrpc-validator.js';
14
+ import { MetricsCollector } from './metrics.js';
15
+ // Default maximum token length (1000 characters)
16
+ const DEFAULT_MAX_TOKEN_LENGTH = 1000;
17
+ export class HttpTransport {
18
+ app;
19
+ server = null;
20
+ sessions = new Map();
21
+ config;
22
+ logger;
23
+ metrics = null;
24
+ cleanupInterval = null;
25
+ messageHandler = null;
26
+ constructor(config, logger) {
27
+ this.config = config;
28
+ this.logger = logger;
29
+ // Initialize metrics if enabled
30
+ if (config.metricsEnabled) {
31
+ this.metrics = new MetricsCollector({
32
+ enabled: true,
33
+ prefix: 'mcp_',
34
+ });
35
+ }
36
+ this.app = express();
37
+ this.setupMiddleware();
38
+ this.setupRoutes();
39
+ }
40
+ /**
41
+ * Setup Express middleware
42
+ *
43
+ * Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
44
+ */
45
+ setupMiddleware() {
46
+ // JSON body parser
47
+ this.app.use(express.json());
48
+ // Metrics: Track request start time
49
+ this.app.use((req, res, next) => {
50
+ req.startTime = Date.now();
51
+ next();
52
+ });
53
+ // Security: Origin validation (DNS rebinding protection)
54
+ this.app.use((req, res, next) => {
55
+ const origin = req.headers.origin;
56
+ // Warn if binding to 0.0.0.0
57
+ if (this.config.host === '0.0.0.0' && !this.hasWarnedAboutBinding) {
58
+ this.logger.warn('HTTP transport bound to 0.0.0.0 - accessible from network. Ensure firewall protection.');
59
+ this.hasWarnedAboutBinding = true;
60
+ }
61
+ // Skip Origin check for localhost
62
+ if (req.hostname === 'localhost' || req.hostname === '127.0.0.1') {
63
+ return next();
64
+ }
65
+ // Validate Origin header for non-localhost
66
+ if (origin && !this.isAllowedOrigin(origin)) {
67
+ this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
68
+ return res.status(403).json({
69
+ error: 'Forbidden',
70
+ message: 'Origin not allowed'
71
+ });
72
+ }
73
+ next();
74
+ });
75
+ // Extract session ID from header
76
+ this.app.use((req, res, next) => {
77
+ const sessionId = req.headers['mcp-session-id'];
78
+ if (sessionId) {
79
+ req.sessionId = sessionId;
80
+ }
81
+ next();
82
+ });
83
+ }
84
+ hasWarnedAboutBinding = false;
85
+ /**
86
+ * Check if origin is allowed
87
+ *
88
+ * Why: Prevent DNS rebinding attacks
89
+ *
90
+ * Supports:
91
+ * - Exact hostname: 'example.com', 'api.example.com'
92
+ * - Wildcard subdomain: '*.example.com'
93
+ * - IPv4 CIDR: '192.168.1.0/24', '10.0.0.0/8'
94
+ * - IPv4 exact: '192.168.1.100'
95
+ */
96
+ isAllowedOrigin(origin) {
97
+ try {
98
+ const url = new URL(origin);
99
+ const hostname = url.hostname;
100
+ // Always allow localhost
101
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
102
+ return true;
103
+ }
104
+ // Allow configured host
105
+ if (hostname === this.config.host) {
106
+ return true;
107
+ }
108
+ // Check custom allowed origins
109
+ if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) {
110
+ for (const allowed of this.config.allowedOrigins) {
111
+ if (this.matchOrigin(hostname, allowed)) {
112
+ return true;
113
+ }
114
+ }
115
+ }
116
+ return false;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ /**
123
+ * Match hostname against allowed origin pattern
124
+ *
125
+ * Supports:
126
+ * - Exact match: 'example.com' === 'example.com'
127
+ * - Wildcard: '*.example.com' matches 'api.example.com', 'web.example.com'
128
+ * - CIDR: '192.168.1.0/24' matches '192.168.1.1' through '192.168.1.254'
129
+ */
130
+ matchOrigin(hostname, pattern) {
131
+ // Exact match
132
+ if (hostname === pattern) {
133
+ return true;
134
+ }
135
+ // Wildcard subdomain match (*.example.com)
136
+ if (pattern.startsWith('*.')) {
137
+ const domain = pattern.substring(2); // Remove '*.'
138
+ return hostname.endsWith('.' + domain) || hostname === domain;
139
+ }
140
+ // CIDR match (IPv4 only)
141
+ if (pattern.includes('/')) {
142
+ return this.matchCIDR(hostname, pattern);
143
+ }
144
+ return false;
145
+ }
146
+ /**
147
+ * Check if IP address is within CIDR range
148
+ *
149
+ * Example: '192.168.1.50' matches '192.168.1.0/24'
150
+ */
151
+ matchCIDR(ip, cidr) {
152
+ // Parse CIDR
153
+ const [range, bits] = cidr.split('/');
154
+ const maskBits = parseInt(bits, 10);
155
+ if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) {
156
+ this.logger.warn('Invalid CIDR mask bits', { cidr });
157
+ return false;
158
+ }
159
+ // Convert IP addresses to 32-bit integers
160
+ const ipInt = this.ipToInt(ip);
161
+ const rangeInt = this.ipToInt(range);
162
+ if (ipInt === null || rangeInt === null) {
163
+ return false;
164
+ }
165
+ // Create mask (e.g., /24 = 0xFFFFFF00)
166
+ const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
167
+ // Compare network portions
168
+ return (ipInt & mask) === (rangeInt & mask);
169
+ }
170
+ /**
171
+ * Convert IPv4 address to 32-bit integer
172
+ *
173
+ * Example: '192.168.1.1' -> 3232235777
174
+ */
175
+ ipToInt(ip) {
176
+ const parts = ip.split('.');
177
+ if (parts.length !== 4) {
178
+ return null;
179
+ }
180
+ let result = 0;
181
+ for (let i = 0; i < 4; i++) {
182
+ const octet = parseInt(parts[i], 10);
183
+ if (isNaN(octet) || octet < 0 || octet > 255) {
184
+ return null;
185
+ }
186
+ result = (result << 8) | octet;
187
+ }
188
+ return result >>> 0; // Unsigned
189
+ }
190
+ /**
191
+ * Setup MCP endpoint routes
192
+ *
193
+ * Why: Single endpoint for POST (client→server) and GET (SSE stream)
194
+ */
195
+ setupRoutes() {
196
+ this.logger.info('Setting up HTTP routes');
197
+ // Security: Rate limiting setup
198
+ const rateLimitEnabled = this.config.rateLimitEnabled !== false; // default: true
199
+ const windowMs = this.config.rateLimitWindowMs || 60000; // 1 minute
200
+ const maxRequests = this.config.rateLimitMaxRequests || 100; // 100 req/min
201
+ const metricsMaxRequests = this.config.rateLimitMetricsMax || 10; // 10 req/min for metrics
202
+ if (rateLimitEnabled) {
203
+ this.logger.info('Rate limiting enabled', {
204
+ windowMs,
205
+ maxRequests,
206
+ metricsMaxRequests,
207
+ });
208
+ }
209
+ // Rate limiter for MCP/SSE endpoints (100 req/min by default)
210
+ const mcpRateLimiter = rateLimitEnabled ? rateLimit({
211
+ windowMs,
212
+ max: maxRequests,
213
+ standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
214
+ legacyHeaders: false, // Disable `X-RateLimit-*` headers
215
+ handler: (req, res) => {
216
+ this.logger.warn('Rate limit exceeded', {
217
+ ip: req.ip,
218
+ path: req.path,
219
+ method: req.method,
220
+ });
221
+ res.status(429).json({
222
+ error: 'Too Many Requests',
223
+ message: `Rate limit exceeded. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`,
224
+ });
225
+ },
226
+ }) : (req, res, next) => next();
227
+ // Rate limiter for metrics endpoint (10 req/min by default)
228
+ const metricsRateLimiter = rateLimitEnabled ? rateLimit({
229
+ windowMs,
230
+ max: metricsMaxRequests,
231
+ standardHeaders: true,
232
+ legacyHeaders: false,
233
+ handler: (req, res) => {
234
+ this.logger.warn('Rate limit exceeded for metrics', {
235
+ ip: req.ip,
236
+ path: req.path,
237
+ });
238
+ res.status(429).json({
239
+ error: 'Too Many Requests',
240
+ message: `Rate limit exceeded for metrics. Max ${metricsMaxRequests} requests per ${windowMs / 1000} seconds.`,
241
+ });
242
+ },
243
+ }) : (req, res, next) => next();
244
+ // Main MCP endpoint - POST for sending messages
245
+ this.app.post('/mcp', mcpRateLimiter, this.handlePost.bind(this));
246
+ this.logger.info('Registered POST /mcp route');
247
+ // Main MCP endpoint - GET for SSE streaming
248
+ this.app.get('/mcp', mcpRateLimiter, this.handleGet.bind(this));
249
+ // Session termination
250
+ this.app.delete('/mcp', mcpRateLimiter, this.handleDelete.bind(this));
251
+ // Legacy alias endpoints - deprecated
252
+ // Why: Backward compatibility for clients using /sse during migration
253
+ this.app.post('/sse', mcpRateLimiter, (req, res, next) => {
254
+ this.logger.warn('Deprecated endpoint used: POST /sse. Please migrate to POST /mcp');
255
+ this.logger.info('Handling POST /sse request');
256
+ return this.handlePost(req, res, next);
257
+ });
258
+ this.app.get('/sse', mcpRateLimiter, (req, res, next) => {
259
+ console.log('=== SSE GET handler called for path:', req.path);
260
+ this.logger.warn('Deprecated endpoint used: GET /sse. Please migrate to GET /mcp');
261
+ this.logger.info(`Handling GET /sse request from: ${req.ip}`);
262
+ return this.handleGet(req, res, next);
263
+ });
264
+ this.logger.info('Registered SSE routes: POST/GET/DELETE /sse');
265
+ this.app.delete('/sse', mcpRateLimiter, (req, res, next) => {
266
+ this.logger.warn('Deprecated endpoint used: DELETE /sse. Please migrate to DELETE /mcp');
267
+ return this.handleDelete(req, res, next);
268
+ });
269
+ // Metrics endpoint (if enabled)
270
+ if (this.config.metricsEnabled) {
271
+ this.app.get(this.config.metricsPath, metricsRateLimiter, this.handleMetrics.bind(this));
272
+ }
273
+ // Health check (with rate limiting)
274
+ this.app.get('/health', mcpRateLimiter, (req, res) => {
275
+ const startTime = Date.now();
276
+ res.json({ status: 'ok', sessions: this.sessions.size });
277
+ if (this.metrics) {
278
+ const duration = (Date.now() - startTime) / 1000;
279
+ this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
280
+ }
281
+ });
282
+ // Debug: SSE route registered
283
+ this.logger.info('SSE routes registered successfully');
284
+ // Default 404 handler
285
+ this.app.use((req, res) => {
286
+ console.log('=== Default 404 handler called for:', req.method, req.path);
287
+ res.status(404).send('<!DOCTYPE html>\n<html>\n<head><title>Error</title></head>\n<body><pre>Cannot ${req.method} ${req.path}</pre></body>\n</html>');
288
+ });
289
+ }
290
+ /**
291
+ * Handle metrics endpoint
292
+ *
293
+ * Why: Prometheus scraping endpoint
294
+ */
295
+ async handleMetrics(req, res) {
296
+ const startTime = Date.now();
297
+ try {
298
+ if (!this.metrics) {
299
+ res.status(404).json({ error: 'Not Found', message: 'Metrics disabled' });
300
+ return;
301
+ }
302
+ const metrics = await this.metrics.getMetrics();
303
+ res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
304
+ res.send(metrics);
305
+ // Don't record metrics call in metrics (avoid recursion)
306
+ }
307
+ catch (error) {
308
+ this.logger.error('Metrics endpoint error', error);
309
+ res.status(500).json({ error: 'Internal Server Error', message: error.message });
310
+ }
311
+ }
312
+ /**
313
+ * Validate token format and length
314
+ *
315
+ * Why centralized: Single source of truth for token validation rules
316
+ *
317
+ * Relaxed validation: Allow common API token characters including colons,
318
+ * to support various token formats (GitLab glpat-, YouTrack perm:, etc.)
319
+ */
320
+ validateToken(token, source) {
321
+ const maxLength = this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH;
322
+ if (token.length > maxLength) {
323
+ throw new Error(`${source} too long (max ${maxLength} characters)`);
324
+ }
325
+ if (token.length === 0) {
326
+ throw new Error(`${source} is empty`);
327
+ }
328
+ // RFC 6750 Bearer token characters + common API token chars (including colons for YouTrack)
329
+ // Allow: alphanumeric, dash, underscore, dot, tilde, plus, slash, equals, colon
330
+ // Note: dash at end of character class to avoid being interpreted as range
331
+ if (!/^[A-Za-z0-9._~+/:=-]+$/.test(token)) {
332
+ throw new Error(`Invalid ${source} format: ...${(token || "").slice(-4)}`);
333
+ }
334
+ }
335
+ /**
336
+ * Extract and validate auth token from request headers
337
+ *
338
+ * Supports:
339
+ * - Authorization: Bearer <token>
340
+ * - X-API-Token: <token>
341
+ *
342
+ * Why strict validation: Prevents header injection attacks
343
+ */
344
+ extractAuthToken(req) {
345
+ const authHeader = req.headers.authorization;
346
+ if (authHeader) {
347
+ // Relaxed Bearer token format validation - allow flexible whitespace
348
+ // Trim whitespace to handle client variations (IntelliJ, VSCode, etc.)
349
+ const trimmed = authHeader.trim();
350
+ const match = trimmed.match(/^Bearer\s+(.+)$/);
351
+ if (!match) {
352
+ throw new Error('Invalid Authorization header format. Expected: Bearer <token>');
353
+ }
354
+ const token = match[1].trim();
355
+ this.validateToken(token, 'Authorization token');
356
+ return token;
357
+ }
358
+ const apiTokenHeader = req.headers['x-api-token'];
359
+ if (apiTokenHeader) {
360
+ if (typeof apiTokenHeader !== 'string') {
361
+ throw new Error('X-API-Token must be a string');
362
+ }
363
+ this.validateToken(apiTokenHeader, 'X-API-Token');
364
+ return apiTokenHeader;
365
+ }
366
+ return undefined;
367
+ }
368
+ /**
369
+ * Handle POST requests - Client sending messages to server
370
+ *
371
+ * MCP Spec: POST can contain requests, notifications, or responses
372
+ */
373
+ async handlePost(req, res) {
374
+ const startTime = Date.now();
375
+ try {
376
+ const sessionId = req.sessionId;
377
+ const body = req.body;
378
+ // Validate Accept header
379
+ const accept = req.headers.accept || '';
380
+ if (!accept.includes('application/json') && !accept.includes('text/event-stream')) {
381
+ res.status(406).json({ error: 'Not Acceptable', message: 'Must accept application/json or text/event-stream' });
382
+ return;
383
+ }
384
+ // Check if this is initialization (no session ID yet)
385
+ const isInitialization = isInitializeRequest(body);
386
+ // Validate session (except for initialization)
387
+ if (!isInitialization && sessionId) {
388
+ const session = this.sessions.get(sessionId);
389
+ if (!session) {
390
+ res.status(404).json({ error: 'Not Found', message: 'Session not found or expired' });
391
+ return;
392
+ }
393
+ this.updateSessionActivity(sessionId);
394
+ }
395
+ else if (!isInitialization && !sessionId) {
396
+ res.status(400).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required (except for initialization)' });
397
+ return;
398
+ }
399
+ // Determine message type
400
+ const messageType = this.getMessageType(body);
401
+ // If only notifications/responses, return 202 Accepted
402
+ if (messageType === 'notification-only' || messageType === 'response-only') {
403
+ if (this.messageHandler) {
404
+ await this.messageHandler(body);
405
+ }
406
+ res.status(202).send();
407
+ return;
408
+ }
409
+ // If contains requests, process and return response
410
+ if (messageType === 'request') {
411
+ if (!this.messageHandler) {
412
+ res.status(500).json({ error: 'Internal Server Error', message: 'Message handler not configured' });
413
+ return;
414
+ }
415
+ // Create session on initialization
416
+ let newSessionId;
417
+ if (isInitialization) {
418
+ // Extract and validate auth token from headers
419
+ const authToken = this.extractAuthToken(req);
420
+ newSessionId = this.createSession(authToken);
421
+ }
422
+ const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId);
423
+ // Check if client prefers SSE stream
424
+ if (accept.includes('text/event-stream')) {
425
+ // Return SSE stream
426
+ this.startSSEResponse(res, response, newSessionId, sessionId);
427
+ }
428
+ else {
429
+ // Return JSON
430
+ if (newSessionId) {
431
+ res.setHeader('Mcp-Session-Id', newSessionId);
432
+ }
433
+ res.json(response);
434
+ }
435
+ return;
436
+ }
437
+ res.status(400).json({ error: 'Bad Request', message: 'Invalid message type' });
438
+ }
439
+ catch (error) {
440
+ this.logger.error('POST request error', error);
441
+ const status = 500;
442
+ res.status(status).json({ error: 'Internal Server Error', message: error.message });
443
+ // Record error metrics
444
+ if (this.metrics) {
445
+ const duration = (Date.now() - startTime) / 1000;
446
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
447
+ }
448
+ }
449
+ finally {
450
+ // Record success metrics (if not already recorded in catch)
451
+ if (this.metrics && res.statusCode !== 500) {
452
+ const duration = (Date.now() - startTime) / 1000;
453
+ this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
454
+ }
455
+ }
456
+ }
457
+ /**
458
+ * Handle GET requests - Client opening SSE stream for server messages
459
+ *
460
+ * MCP Spec: GET opens SSE stream for server-initiated requests/notifications
461
+ */
462
+ async handleGet(req, res) {
463
+ const startTime = Date.now();
464
+ try {
465
+ const sessionId = req.sessionId;
466
+ const lastEventId = req.headers['last-event-id'];
467
+ // Validate Accept header
468
+ const accept = req.headers.accept || '';
469
+ if (!accept.includes('text/event-stream')) {
470
+ res.status(405).json({ error: 'Method Not Allowed', message: 'Must accept text/event-stream' });
471
+ return;
472
+ }
473
+ // Validate session
474
+ if (!sessionId) {
475
+ res.status(400).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
476
+ return;
477
+ }
478
+ const session = this.sessions.get(sessionId);
479
+ if (!session) {
480
+ res.status(404).json({ error: 'Not Found', message: 'Session not found or expired' });
481
+ return;
482
+ }
483
+ this.updateSessionActivity(sessionId);
484
+ // Start SSE stream
485
+ this.startSSEStream(res, sessionId, lastEventId);
486
+ // Record metrics for successful SSE start
487
+ if (this.metrics) {
488
+ const duration = (Date.now() - startTime) / 1000;
489
+ this.metrics.recordHttpRequest(req.method, req.path, 200, duration);
490
+ }
491
+ }
492
+ catch (error) {
493
+ this.logger.error('GET request error', error);
494
+ const status = 500;
495
+ if (!res.headersSent) {
496
+ res.status(status).json({ error: 'Internal Server Error', message: error.message });
497
+ }
498
+ // Record error metrics
499
+ if (this.metrics) {
500
+ const duration = (Date.now() - startTime) / 1000;
501
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
502
+ }
503
+ }
504
+ }
505
+ /**
506
+ * Handle DELETE requests - Client terminating session
507
+ *
508
+ * MCP Spec: DELETE explicitly terminates session
509
+ */
510
+ handleDelete(req, res) {
511
+ const startTime = Date.now();
512
+ const sessionId = req.sessionId;
513
+ if (!sessionId) {
514
+ const status = 400;
515
+ res.status(status).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
516
+ if (this.metrics) {
517
+ const duration = (Date.now() - startTime) / 1000;
518
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
519
+ }
520
+ return;
521
+ }
522
+ const session = this.sessions.get(sessionId);
523
+ if (!session) {
524
+ const status = 404;
525
+ res.status(status).json({ error: 'Not Found', message: 'Session not found' });
526
+ if (this.metrics) {
527
+ const duration = (Date.now() - startTime) / 1000;
528
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
529
+ }
530
+ return;
531
+ }
532
+ this.destroySession(sessionId);
533
+ const status = 204;
534
+ res.status(status).send();
535
+ if (this.metrics) {
536
+ const duration = (Date.now() - startTime) / 1000;
537
+ this.metrics.recordHttpRequest(req.method, req.path, status, duration);
538
+ }
539
+ }
540
+ /**
541
+ * Start SSE response for a POST request
542
+ *
543
+ * Why: Returns response via SSE stream, allows server-initiated messages
544
+ */
545
+ startSSEResponse(res, response, newSessionId, sessionId) {
546
+ res.setHeader('Content-Type', 'text/event-stream');
547
+ res.setHeader('Cache-Control', 'no-cache');
548
+ res.setHeader('Connection', 'keep-alive');
549
+ if (newSessionId) {
550
+ res.setHeader('Mcp-Session-Id', newSessionId);
551
+ }
552
+ // Send response
553
+ const eventId = Date.now();
554
+ res.write(`id: ${eventId}\n`);
555
+ res.write(`data: ${JSON.stringify(response)}\n\n`);
556
+ // Close stream
557
+ res.end();
558
+ }
559
+ /**
560
+ * Start SSE stream for GET request
561
+ *
562
+ * Why: Allows server to send requests/notifications to client
563
+ */
564
+ startSSEStream(res, sessionId, lastEventId) {
565
+ res.setHeader('Content-Type', 'text/event-stream');
566
+ res.setHeader('Cache-Control', 'no-cache');
567
+ res.setHeader('Connection', 'keep-alive');
568
+ const streamId = crypto.randomBytes(16).toString('hex');
569
+ const session = this.sessions.get(sessionId);
570
+ const streamState = {
571
+ streamId,
572
+ lastEventId: lastEventId ? parseInt(lastEventId, 10) : 0,
573
+ messageQueue: [],
574
+ active: true,
575
+ response: res,
576
+ };
577
+ session.sseStreams.set(streamId, streamState);
578
+ // Replay missed messages if resuming
579
+ if (lastEventId) {
580
+ this.replayMessages(res, streamState);
581
+ }
582
+ // Setup heartbeat if enabled
583
+ let heartbeatInterval = null;
584
+ if (this.config.heartbeatEnabled) {
585
+ heartbeatInterval = setInterval(() => {
586
+ if (streamState.active) {
587
+ res.write(':ping\n\n');
588
+ }
589
+ }, this.config.heartbeatIntervalMs);
590
+ }
591
+ // Handle client disconnect
592
+ res.on('close', () => {
593
+ streamState.active = false;
594
+ if (heartbeatInterval) {
595
+ clearInterval(heartbeatInterval);
596
+ }
597
+ this.logger.info('SSE stream closed', { sessionId, streamId });
598
+ });
599
+ this.logger.info('SSE stream opened', { sessionId, streamId, resuming: !!lastEventId });
600
+ }
601
+ /**
602
+ * Replay messages after Last-Event-ID
603
+ *
604
+ * Why: Resumability - client can reconnect and receive missed messages
605
+ */
606
+ replayMessages(res, streamState) {
607
+ const missedMessages = streamState.messageQueue.filter(msg => msg.eventId > streamState.lastEventId);
608
+ for (const msg of missedMessages) {
609
+ res.write(`id: ${msg.eventId}\n`);
610
+ res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
611
+ }
612
+ this.logger.info('Replayed messages', { count: missedMessages.length, streamId: streamState.streamId });
613
+ }
614
+ /**
615
+ * Send message to client via SSE
616
+ *
617
+ * Why: Server-initiated requests/notifications
618
+ */
619
+ sendToClient(sessionId, message) {
620
+ const session = this.sessions.get(sessionId);
621
+ if (!session) {
622
+ this.logger.warn('Cannot send to client: session not found', { sessionId });
623
+ return;
624
+ }
625
+ const eventId = Date.now();
626
+ const queuedMessage = {
627
+ eventId,
628
+ data: message,
629
+ timestamp: Date.now(),
630
+ };
631
+ // Send to all active streams for this session
632
+ for (const [streamId, streamState] of session.sseStreams) {
633
+ if (streamState.active) {
634
+ // Queue for resumability
635
+ streamState.messageQueue.push(queuedMessage);
636
+ // Keep only last 100 messages
637
+ if (streamState.messageQueue.length > 100) {
638
+ streamState.messageQueue.shift();
639
+ }
640
+ }
641
+ }
642
+ }
643
+ /**
644
+ * Determine message type (request, notification, response)
645
+ */
646
+ getMessageType(body) {
647
+ if (Array.isArray(body)) {
648
+ // Batch
649
+ const hasRequest = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && 'id' in msg);
650
+ const hasNotification = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && !('id' in msg));
651
+ const hasResponse = body.some((msg) => typeof msg === 'object' && msg !== null && ('result' in msg || 'error' in msg));
652
+ if (hasRequest)
653
+ return 'request';
654
+ if (hasNotification && !hasResponse)
655
+ return 'notification-only';
656
+ if (hasResponse && !hasNotification)
657
+ return 'response-only';
658
+ return 'mixed';
659
+ }
660
+ else if (typeof body === 'object' && body !== null) {
661
+ const msg = body;
662
+ if ('method' in msg) {
663
+ return 'id' in msg ? 'request' : 'notification-only';
664
+ }
665
+ if ('result' in msg || 'error' in msg) {
666
+ return 'response-only';
667
+ }
668
+ }
669
+ return 'unknown';
670
+ }
671
+ /**
672
+ * Create new session
673
+ *
674
+ * Why: Stateful sessions for MCP protocol
675
+ */
676
+ createSession(authToken) {
677
+ // Validate token if provided (defense in depth)
678
+ if (authToken) {
679
+ this.validateToken(authToken, 'Session auth token');
680
+ }
681
+ const sessionId = crypto.randomUUID();
682
+ const session = {
683
+ id: sessionId,
684
+ createdAt: Date.now(),
685
+ lastActivityAt: Date.now(),
686
+ sseStreams: new Map(),
687
+ authToken,
688
+ };
689
+ this.sessions.set(sessionId, session);
690
+ this.logger.info('Session created', { sessionId, hasAuthToken: !!authToken });
691
+ // Record metrics
692
+ if (this.metrics) {
693
+ this.metrics.recordSessionCreated();
694
+ }
695
+ return sessionId;
696
+ }
697
+ /**
698
+ * Update session activity timestamp
699
+ */
700
+ updateSessionActivity(sessionId) {
701
+ const session = this.sessions.get(sessionId);
702
+ if (session) {
703
+ session.lastActivityAt = Date.now();
704
+ }
705
+ }
706
+ /**
707
+ * Destroy session and cleanup resources
708
+ *
709
+ * Why: Free memory, close streams
710
+ */
711
+ destroySession(sessionId) {
712
+ const session = this.sessions.get(sessionId);
713
+ if (session) {
714
+ // Close all active SSE streams
715
+ for (const [, streamState] of session.sseStreams) {
716
+ streamState.active = false;
717
+ // Close the HTTP response to terminate the SSE connection
718
+ try {
719
+ if (!streamState.response.headersSent || !streamState.response.writableEnded) {
720
+ streamState.response.end();
721
+ }
722
+ }
723
+ catch (error) {
724
+ // Ignore errors if response is already closed
725
+ this.logger.debug('Failed to close SSE response', { error: error.message });
726
+ }
727
+ }
728
+ session.sseStreams.clear();
729
+ this.sessions.delete(sessionId);
730
+ this.logger.info('Session destroyed', { sessionId });
731
+ // Notify session destruction listeners (for cleanup in MCPServer)
732
+ this.notifySessionDestroyed(sessionId);
733
+ // Record metrics
734
+ if (this.metrics) {
735
+ this.metrics.recordSessionDestroyed();
736
+ }
737
+ }
738
+ }
739
+ /**
740
+ * Session destruction listeners for cleanup in other components
741
+ */
742
+ sessionDestroyedListeners = [];
743
+ /**
744
+ * Register listener for session destruction events
745
+ *
746
+ * Why: Allows MCPServer to cleanup per-session HTTP clients
747
+ */
748
+ onSessionDestroyed(listener) {
749
+ this.sessionDestroyedListeners.push(listener);
750
+ }
751
+ /**
752
+ * Notify all listeners about session destruction
753
+ */
754
+ notifySessionDestroyed(sessionId) {
755
+ for (const listener of this.sessionDestroyedListeners) {
756
+ try {
757
+ listener(sessionId);
758
+ }
759
+ catch (error) {
760
+ this.logger.error('Session destroyed listener error', error);
761
+ }
762
+ }
763
+ }
764
+ /**
765
+ * Cleanup expired sessions
766
+ *
767
+ * Why: Prevent memory leaks, enforce session timeout
768
+ */
769
+ cleanupExpiredSessions() {
770
+ const now = Date.now();
771
+ const expiredSessions = [];
772
+ for (const [sessionId, session] of this.sessions) {
773
+ const age = now - session.lastActivityAt;
774
+ if (age > this.config.sessionTimeoutMs) {
775
+ expiredSessions.push(sessionId);
776
+ }
777
+ }
778
+ for (const sessionId of expiredSessions) {
779
+ this.destroySession(sessionId);
780
+ }
781
+ if (expiredSessions.length > 0) {
782
+ this.logger.info('Cleaned up expired sessions', { count: expiredSessions.length });
783
+ }
784
+ }
785
+ /**
786
+ * Get auth token from session
787
+ *
788
+ * Why public: Allows MCPServer to securely access session tokens without breaking encapsulation
789
+ */
790
+ getSessionToken(sessionId) {
791
+ const session = this.sessions.get(sessionId);
792
+ return session?.authToken;
793
+ }
794
+ /**
795
+ * Set message handler for processing incoming JSON-RPC messages
796
+ */
797
+ setMessageHandler(handler) {
798
+ this.messageHandler = handler;
799
+ }
800
+ /**
801
+ * Start HTTP server
802
+ */
803
+ async start() {
804
+ return new Promise((resolve, reject) => {
805
+ try {
806
+ this.server = this.app.listen(this.config.port, this.config.host, () => {
807
+ this.logger.info('HTTP transport started', {
808
+ host: this.config.host,
809
+ port: this.config.port,
810
+ heartbeat: this.config.heartbeatEnabled,
811
+ metrics: this.config.metricsEnabled,
812
+ });
813
+ // Start session cleanup interval
814
+ this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), 60000 // Check every minute
815
+ );
816
+ resolve();
817
+ });
818
+ this.server.on('error', reject);
819
+ }
820
+ catch (error) {
821
+ reject(error);
822
+ }
823
+ });
824
+ }
825
+ /**
826
+ * Stop HTTP server
827
+ */
828
+ async stop() {
829
+ if (this.cleanupInterval) {
830
+ clearInterval(this.cleanupInterval);
831
+ this.cleanupInterval = null;
832
+ }
833
+ // Destroy all sessions
834
+ for (const sessionId of this.sessions.keys()) {
835
+ this.destroySession(sessionId);
836
+ }
837
+ if (this.server) {
838
+ return new Promise((resolve, reject) => {
839
+ this.server.close((err) => {
840
+ if (err)
841
+ reject(err);
842
+ else {
843
+ this.logger.info('HTTP transport stopped');
844
+ resolve();
845
+ }
846
+ });
847
+ });
848
+ }
849
+ }
850
+ }
851
+ //# sourceMappingURL=http-transport.js.map