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.
- package/LICENSE.md +7 -0
- package/README.md +489 -0
- package/dist/composite-executor.d.ts +65 -0
- package/dist/composite-executor.d.ts.map +1 -0
- package/dist/composite-executor.js +147 -0
- package/dist/composite-executor.js.map +1 -0
- package/dist/constants.d.ts +36 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +36 -0
- package/dist/constants.js.map +1 -0
- package/dist/http-transport.d.ts +195 -0
- package/dist/http-transport.d.ts.map +1 -0
- package/dist/http-transport.js +760 -0
- package/dist/http-transport.js.map +1 -0
- package/dist/interceptors.d.ts +74 -0
- package/dist/interceptors.d.ts.map +1 -0
- package/dist/interceptors.js +220 -0
- package/dist/interceptors.js.map +1 -0
- package/dist/logger.d.ts +81 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +264 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp-server.d.ts +110 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +568 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/metrics.d.ts +86 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +229 -0
- package/dist/metrics.js.map +1 -0
- package/dist/openapi-parser.d.ts +35 -0
- package/dist/openapi-parser.d.ts.map +1 -0
- package/dist/openapi-parser.js +160 -0
- package/dist/openapi-parser.js.map +1 -0
- package/dist/profile-loader.d.ts +25 -0
- package/dist/profile-loader.d.ts.map +1 -0
- package/dist/profile-loader.js +134 -0
- package/dist/profile-loader.js.map +1 -0
- package/dist/schema-validator.d.ts +32 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +126 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/scripts/validate-profile.d.ts +9 -0
- package/dist/scripts/validate-profile.d.ts.map +1 -0
- package/dist/scripts/validate-profile.js +289 -0
- package/dist/scripts/validate-profile.js.map +1 -0
- package/dist/scripts/validate-schema.d.ts +9 -0
- package/dist/scripts/validate-schema.d.ts.map +1 -0
- package/dist/scripts/validate-schema.js +84 -0
- package/dist/scripts/validate-schema.js.map +1 -0
- package/dist/src/composite-executor.d.ts +75 -0
- package/dist/src/composite-executor.d.ts.map +1 -0
- package/dist/src/composite-executor.js +175 -0
- package/dist/src/composite-executor.js.map +1 -0
- package/dist/src/constants.d.ts +36 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +36 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/dag-executor.d.ts +49 -0
- package/dist/src/dag-executor.d.ts.map +1 -0
- package/dist/src/dag-executor.js +138 -0
- package/dist/src/dag-executor.js.map +1 -0
- package/dist/src/errors.d.ts +47 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +99 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/generated-schemas.d.ts +661 -0
- package/dist/src/generated-schemas.d.ts.map +1 -0
- package/dist/src/generated-schemas.js +66 -0
- package/dist/src/generated-schemas.js.map +1 -0
- package/dist/src/http-client-factory.d.ts +62 -0
- package/dist/src/http-client-factory.d.ts.map +1 -0
- package/dist/src/http-client-factory.js +121 -0
- package/dist/src/http-client-factory.js.map +1 -0
- package/dist/src/http-transport.d.ts +194 -0
- package/dist/src/http-transport.d.ts.map +1 -0
- package/dist/src/http-transport.js +851 -0
- package/dist/src/http-transport.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +59 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/interceptors.d.ts +78 -0
- package/dist/src/interceptors.d.ts.map +1 -0
- package/dist/src/interceptors.js +252 -0
- package/dist/src/interceptors.js.map +1 -0
- package/dist/src/jsonrpc-validator.d.ts +27 -0
- package/dist/src/jsonrpc-validator.d.ts.map +1 -0
- package/dist/src/jsonrpc-validator.js +58 -0
- package/dist/src/jsonrpc-validator.js.map +1 -0
- package/dist/src/lib.d.ts +8 -0
- package/dist/src/lib.d.ts.map +1 -0
- package/dist/src/lib.js +7 -0
- package/dist/src/lib.js.map +1 -0
- package/dist/src/logger.d.ts +81 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +264 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/mcp-server.d.ts +117 -0
- package/dist/src/mcp-server.d.ts.map +1 -0
- package/dist/src/mcp-server.js +621 -0
- package/dist/src/mcp-server.js.map +1 -0
- package/dist/src/metrics.d.ts +86 -0
- package/dist/src/metrics.d.ts.map +1 -0
- package/dist/src/metrics.js +229 -0
- package/dist/src/metrics.js.map +1 -0
- package/dist/src/naming-warnings.d.ts +23 -0
- package/dist/src/naming-warnings.d.ts.map +1 -0
- package/dist/src/naming-warnings.js +83 -0
- package/dist/src/naming-warnings.js.map +1 -0
- package/dist/src/naming.d.ts +58 -0
- package/dist/src/naming.d.ts.map +1 -0
- package/dist/src/naming.js +510 -0
- package/dist/src/naming.js.map +1 -0
- package/dist/src/openapi-parser.d.ts +49 -0
- package/dist/src/openapi-parser.d.ts.map +1 -0
- package/dist/src/openapi-parser.js +216 -0
- package/dist/src/openapi-parser.js.map +1 -0
- package/dist/src/profile-loader.d.ts +77 -0
- package/dist/src/profile-loader.d.ts.map +1 -0
- package/dist/src/profile-loader.js +443 -0
- package/dist/src/profile-loader.js.map +1 -0
- package/dist/src/schema-validator.d.ts +30 -0
- package/dist/src/schema-validator.d.ts.map +1 -0
- package/dist/src/schema-validator.js +115 -0
- package/dist/src/schema-validator.js.map +1 -0
- package/dist/src/testing/fixtures.d.ts +268 -0
- package/dist/src/testing/fixtures.d.ts.map +1 -0
- package/dist/src/testing/fixtures.js +210 -0
- package/dist/src/testing/fixtures.js.map +1 -0
- package/dist/src/testing/mock-gitlab-server.d.ts +34 -0
- package/dist/src/testing/mock-gitlab-server.d.ts.map +1 -0
- package/dist/src/testing/mock-gitlab-server.js +351 -0
- package/dist/src/testing/mock-gitlab-server.js.map +1 -0
- package/dist/src/testing/mock-utils.d.ts +41 -0
- package/dist/src/testing/mock-utils.d.ts.map +1 -0
- package/dist/src/testing/mock-utils.js +59 -0
- package/dist/src/testing/mock-utils.js.map +1 -0
- package/dist/src/testing/test-http-utils.d.ts +52 -0
- package/dist/src/testing/test-http-utils.d.ts.map +1 -0
- package/dist/src/testing/test-http-utils.js +109 -0
- package/dist/src/testing/test-http-utils.js.map +1 -0
- package/dist/src/testing/test-types.d.ts +76 -0
- package/dist/src/testing/test-types.d.ts.map +1 -0
- package/dist/src/testing/test-types.js +7 -0
- package/dist/src/testing/test-types.js.map +1 -0
- package/dist/src/tool-generator.d.ts +43 -0
- package/dist/src/tool-generator.d.ts.map +1 -0
- package/dist/src/tool-generator.js +123 -0
- package/dist/src/tool-generator.js.map +1 -0
- package/dist/src/types/http-transport.d.ts +45 -0
- package/dist/src/types/http-transport.d.ts.map +1 -0
- package/dist/src/types/http-transport.js +8 -0
- package/dist/src/types/http-transport.js.map +1 -0
- package/dist/src/types/openapi.d.ts +50 -0
- package/dist/src/types/openapi.d.ts.map +1 -0
- package/dist/src/types/openapi.js +9 -0
- package/dist/src/types/openapi.js.map +1 -0
- package/dist/src/types/profile.d.ts +80 -0
- package/dist/src/types/profile.d.ts.map +1 -0
- package/dist/src/types/profile.js +9 -0
- package/dist/src/types/profile.js.map +1 -0
- package/dist/src/validation-utils.d.ts +15 -0
- package/dist/src/validation-utils.d.ts.map +1 -0
- package/dist/src/validation-utils.js +25 -0
- package/dist/src/validation-utils.js.map +1 -0
- package/dist/testing/fixtures.d.ts +186 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +135 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/http-integration.test.d.ts +7 -0
- package/dist/testing/http-integration.test.d.ts.map +1 -0
- package/dist/testing/http-integration.test.js +383 -0
- package/dist/testing/http-integration.test.js.map +1 -0
- package/dist/testing/http-multiuser.test.d.ts +10 -0
- package/dist/testing/http-multiuser.test.d.ts.map +1 -0
- package/dist/testing/http-multiuser.test.js +255 -0
- package/dist/testing/http-multiuser.test.js.map +1 -0
- package/dist/testing/integration.test.d.ts +8 -0
- package/dist/testing/integration.test.d.ts.map +1 -0
- package/dist/testing/integration.test.js +247 -0
- package/dist/testing/integration.test.js.map +1 -0
- package/dist/testing/mock-gitlab-server.d.ts +34 -0
- package/dist/testing/mock-gitlab-server.d.ts.map +1 -0
- package/dist/testing/mock-gitlab-server.js +224 -0
- package/dist/testing/mock-gitlab-server.js.map +1 -0
- package/dist/testing/test-types.d.ts +59 -0
- package/dist/testing/test-types.d.ts.map +1 -0
- package/dist/testing/test-types.js +7 -0
- package/dist/testing/test-types.js.map +1 -0
- package/dist/tool-generator.d.ts +43 -0
- package/dist/tool-generator.d.ts.map +1 -0
- package/dist/tool-generator.js +123 -0
- package/dist/tool-generator.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/http-transport.d.ts +39 -0
- package/dist/types/http-transport.d.ts.map +1 -0
- package/dist/types/http-transport.js +8 -0
- package/dist/types/http-transport.js.map +1 -0
- package/dist/types/openapi.d.ts +50 -0
- package/dist/types/openapi.d.ts.map +1 -0
- package/dist/types/openapi.js +9 -0
- package/dist/types/openapi.js.map +1 -0
- package/dist/types/profile.d.ts +76 -0
- package/dist/types/profile.d.ts.map +1 -0
- package/dist/types/profile.js +9 -0
- package/dist/types/profile.js.map +1 -0
- package/package.json +84 -0
- 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
|