@uteamup/mcp-server 1.0.0-beta.9 → 2.4.0-beta.9
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/CHANGELOG.md +670 -0
- package/README.md +123 -62
- package/assets/logo.png +0 -0
- package/assets/logo.svg +11 -0
- package/index.js +428 -205
- package/lib/config.js +60 -5
- package/lib/logger.js +54 -2
- package/lib/mcp-http-headers.js +87 -0
- package/lib/retry.js +12 -3
- package/lib/transport-security.js +252 -0
- package/openapi-actions.json +234 -0
- package/package.json +28 -14
- package/server.json +77 -0
package/index.js
CHANGED
|
@@ -16,10 +16,20 @@
|
|
|
16
16
|
|
|
17
17
|
const https = require('https');
|
|
18
18
|
const crypto = require('crypto');
|
|
19
|
-
const { loadConfig,
|
|
20
|
-
const { trace, debug, info,
|
|
19
|
+
const { loadConfig, printConfigSummary } = require('./lib/config');
|
|
20
|
+
const { trace, debug, info, error } = require('./lib/logger');
|
|
21
21
|
const { startTimer, recordOAuthAttempt, recordRequest, recordCacheHit, recordCacheMiss, printMetrics } = require('./lib/metrics');
|
|
22
22
|
const { retryWithBackoff } = require('./lib/retry');
|
|
23
|
+
const { buildMcpForwardHeaders } = require('./lib/mcp-http-headers');
|
|
24
|
+
const {
|
|
25
|
+
BoundedLineBuffer,
|
|
26
|
+
ProxyTransportError,
|
|
27
|
+
attachRequestTimeout,
|
|
28
|
+
collectResponseBody,
|
|
29
|
+
getMcpRetryLimit,
|
|
30
|
+
parseOAuthTokenResponse,
|
|
31
|
+
toPublicErrorMessage
|
|
32
|
+
} = require('./lib/transport-security');
|
|
23
33
|
|
|
24
34
|
// Package version
|
|
25
35
|
const VERSION = '1.0.0-beta.9';
|
|
@@ -48,11 +58,14 @@ ENVIRONMENT VARIABLES:
|
|
|
48
58
|
|
|
49
59
|
Optional:
|
|
50
60
|
UTEAMUP_API_BASE_URL API endpoint (default: https://api.uteamup.com)
|
|
61
|
+
UTEAMUP_MCP_PATH /mcp or /mcp/read-only (default: /mcp)
|
|
51
62
|
UTEAMUP_LOG_LEVEL Log level: TRACE, DEBUG, INFO, WARN, ERROR (default: INFO)
|
|
52
63
|
UTEAMUP_LOG_FORMAT Log format: text, json (default: text)
|
|
53
64
|
UTEAMUP_DEBUG Enable debug mode (1 or true) - legacy, use LOG_LEVEL instead
|
|
54
65
|
UTEAMUP_REQUEST_TIMEOUT Request timeout in ms (default: 30000)
|
|
55
66
|
UTEAMUP_MAX_RETRIES Max retry attempts (default: 3)
|
|
67
|
+
UTEAMUP_MAX_REQUEST_BYTES Max JSON-RPC request bytes (default: 1048576)
|
|
68
|
+
UTEAMUP_MAX_RESPONSE_BYTES Max backend response bytes (default: 10485760)
|
|
56
69
|
UTEAMUP_TOKEN_CACHE_DIR Persistent token cache directory (optional)
|
|
57
70
|
UTEAMUP_TOKEN_REFRESH_MARGIN Token refresh margin in seconds (default: 300)
|
|
58
71
|
|
|
@@ -103,7 +116,11 @@ if (args.includes('--validate-config')) {
|
|
|
103
116
|
// Token cache
|
|
104
117
|
let accessToken = null;
|
|
105
118
|
let tokenExpiry = null;
|
|
119
|
+
let pendingTokenRequest = null;
|
|
106
120
|
let requestIdCounter = 0;
|
|
121
|
+
const OAUTH_REDIRECT_URI = 'http://localhost:3000/callback';
|
|
122
|
+
const STDIN_CLOSE_GRACE_MS = 2000;
|
|
123
|
+
const MAX_FORWARDED_ERROR_LENGTH = 500;
|
|
107
124
|
|
|
108
125
|
/**
|
|
109
126
|
* Generates a unique request ID
|
|
@@ -113,6 +130,20 @@ function generateRequestId() {
|
|
|
113
130
|
return `req_${Date.now()}_${++requestIdCounter}`;
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Creates a client-safe HTTP error while preserving status for retry policy.
|
|
135
|
+
* @param {string} message
|
|
136
|
+
* @param {number|undefined} statusCode
|
|
137
|
+
* @returns {ProxyTransportError}
|
|
138
|
+
*/
|
|
139
|
+
function createHttpError(message, statusCode) {
|
|
140
|
+
const transportError = new ProxyTransportError(message);
|
|
141
|
+
if (Number.isInteger(statusCode)) {
|
|
142
|
+
transportError.statusCode = statusCode;
|
|
143
|
+
}
|
|
144
|
+
return transportError;
|
|
145
|
+
}
|
|
146
|
+
|
|
116
147
|
/**
|
|
117
148
|
* Generates a PKCE (Proof Key for Code Exchange) code verifier and challenge pair.
|
|
118
149
|
* @returns {{verifier: string, challenge: string}} PKCE pair with S256 challenge
|
|
@@ -130,7 +161,7 @@ function generatePKCE() {
|
|
|
130
161
|
/**
|
|
131
162
|
* Gets an OAuth access token, using cached token if still valid.
|
|
132
163
|
* Implements full OAuth 2.0 Authorization Code + PKCE flow with retry logic.
|
|
133
|
-
* @returns {Promise<string>} JWT access token valid for
|
|
164
|
+
* @returns {Promise<string>} JWT access token valid for the backend-reported lifetime
|
|
134
165
|
* @throws {Error} If authentication fails or credentials are invalid
|
|
135
166
|
*/
|
|
136
167
|
async function getAccessToken() {
|
|
@@ -145,6 +176,28 @@ async function getAccessToken() {
|
|
|
145
176
|
return accessToken;
|
|
146
177
|
}
|
|
147
178
|
|
|
179
|
+
// Requests run concurrently, so share one OAuth flow instead of starting one per request.
|
|
180
|
+
if (!pendingTokenRequest) {
|
|
181
|
+
pendingTokenRequest = requestNewAccessToken().finally(() => {
|
|
182
|
+
pendingTokenRequest = null;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return pendingTokenRequest;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Drops the cached token after the backend rejected it, unless a newer token
|
|
190
|
+
* has already replaced it.
|
|
191
|
+
* @param {string} rejectedToken
|
|
192
|
+
*/
|
|
193
|
+
function clearAccessToken(rejectedToken) {
|
|
194
|
+
if (accessToken === rejectedToken) {
|
|
195
|
+
accessToken = null;
|
|
196
|
+
tokenExpiry = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function requestNewAccessToken() {
|
|
148
201
|
recordCacheMiss();
|
|
149
202
|
info('Getting new access token');
|
|
150
203
|
|
|
@@ -160,41 +213,63 @@ async function getAccessToken() {
|
|
|
160
213
|
const authParams = new URLSearchParams({
|
|
161
214
|
response_type: 'code',
|
|
162
215
|
client_id: config.apiKey,
|
|
163
|
-
redirect_uri:
|
|
216
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
164
217
|
code_challenge: challenge,
|
|
165
218
|
code_challenge_method: 'S256',
|
|
166
219
|
scope: 'mcp:full'
|
|
167
220
|
});
|
|
168
221
|
|
|
169
|
-
trace('Requesting authorization code', {
|
|
170
|
-
clientId: config.apiKey.substring(0, 8) + '...',
|
|
171
|
-
scope: 'mcp:full'
|
|
172
|
-
});
|
|
222
|
+
trace('Requesting authorization code', { scope: 'mcp:full' });
|
|
173
223
|
|
|
174
|
-
|
|
175
|
-
|
|
224
|
+
const authorizeUrl = new URL('/oauth/authorize', config.baseUrl);
|
|
225
|
+
authorizeUrl.search = authParams.toString();
|
|
226
|
+
const authRequest = https.get(authorizeUrl, {
|
|
227
|
+
// UTEAMUP-SEC-2026-05-02-008: TLS verification is on by default;
|
|
228
|
+
// UTEAMUP_INSECURE_TLS=1 opts in for self-signed dev certs.
|
|
229
|
+
// Production host (api.uteamup.com) ignores the flag.
|
|
230
|
+
rejectUnauthorized: !config.insecureTLS,
|
|
176
231
|
timeout: config.requestTimeout
|
|
177
232
|
}, (res) => {
|
|
178
233
|
const location = res.headers.location || '';
|
|
179
|
-
|
|
234
|
+
res.resume();
|
|
235
|
+
debug('Authorization redirect received', {
|
|
236
|
+
statusCode: res.statusCode,
|
|
237
|
+
hasLocation: Boolean(location)
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
let redirectUrl;
|
|
241
|
+
try {
|
|
242
|
+
redirectUrl = new URL(location, OAUTH_REDIRECT_URI);
|
|
243
|
+
} catch {
|
|
244
|
+
reject(new ProxyTransportError('OAuth authorization returned an invalid redirect'));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const expectedRedirect = new URL(OAUTH_REDIRECT_URI);
|
|
249
|
+
if (
|
|
250
|
+
redirectUrl.origin !== expectedRedirect.origin ||
|
|
251
|
+
redirectUrl.pathname !== expectedRedirect.pathname
|
|
252
|
+
) {
|
|
253
|
+
reject(new ProxyTransportError('OAuth authorization returned an unexpected redirect'));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
180
256
|
|
|
181
257
|
// Check for OAuth error in redirect
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
const errorType = url.searchParams.get('error');
|
|
185
|
-
const description = url.searchParams.get('error_description');
|
|
186
|
-
reject(new Error(`OAuth authorization failed: ${errorType} - ${description || 'Unknown error'}`));
|
|
258
|
+
if (redirectUrl.searchParams.has('error')) {
|
|
259
|
+
reject(new ProxyTransportError('OAuth authorization was denied'));
|
|
187
260
|
return;
|
|
188
261
|
}
|
|
189
262
|
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
263
|
+
const authorizationCode = redirectUrl.searchParams.get('code');
|
|
264
|
+
if (authorizationCode) {
|
|
192
265
|
debug('Authorization code received');
|
|
193
|
-
resolve(
|
|
266
|
+
resolve(authorizationCode);
|
|
194
267
|
} else {
|
|
195
|
-
reject(new
|
|
268
|
+
reject(new ProxyTransportError('OAuth authorization did not return a code'));
|
|
196
269
|
}
|
|
197
|
-
})
|
|
270
|
+
});
|
|
271
|
+
authRequest.once('error', reject);
|
|
272
|
+
attachRequestTimeout(authRequest, 'OAuth authorization request');
|
|
198
273
|
});
|
|
199
274
|
},
|
|
200
275
|
{
|
|
@@ -203,79 +278,66 @@ async function getAccessToken() {
|
|
|
203
278
|
}
|
|
204
279
|
);
|
|
205
280
|
|
|
206
|
-
info('Authorization code received'
|
|
207
|
-
codePrefix: authCode.substring(0, 20) + '...'
|
|
208
|
-
});
|
|
281
|
+
info('Authorization code received');
|
|
209
282
|
|
|
210
|
-
// Step 2: Exchange for access token with retry
|
|
283
|
+
// Step 2: Exchange for access token with retry. Naming the MCP endpoint as the resource
|
|
284
|
+
// (RFC 8707) gets a token the backend accepts only there, never on its REST API.
|
|
211
285
|
const tokenData = new URLSearchParams({
|
|
212
286
|
grant_type: 'authorization_code',
|
|
213
287
|
code: authCode,
|
|
214
|
-
redirect_uri:
|
|
288
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
215
289
|
client_id: config.apiKey,
|
|
216
290
|
client_secret: config.secret,
|
|
217
|
-
code_verifier: verifier
|
|
291
|
+
code_verifier: verifier,
|
|
292
|
+
resource: new URL(config.mcpPath, config.baseUrl).href
|
|
218
293
|
}).toString();
|
|
219
294
|
|
|
220
|
-
const
|
|
295
|
+
const tokenResponse = await retryWithBackoff(
|
|
221
296
|
async () => {
|
|
222
297
|
return new Promise((resolve, reject) => {
|
|
223
298
|
trace('Exchanging authorization code for access token');
|
|
224
299
|
|
|
225
|
-
const req = https.request(
|
|
300
|
+
const req = https.request(new URL('/oauth/token', config.baseUrl), {
|
|
226
301
|
method: 'POST',
|
|
227
302
|
headers: {
|
|
228
303
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
229
304
|
'Content-Length': Buffer.byteLength(tokenData)
|
|
230
305
|
},
|
|
231
|
-
|
|
306
|
+
// UTEAMUP-SEC-2026-05-02-008: see authorize handler above.
|
|
307
|
+
rejectUnauthorized: !config.insecureTLS,
|
|
232
308
|
timeout: config.requestTimeout
|
|
233
|
-
}, (res) => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
res.on('end', () => {
|
|
309
|
+
}, async (res) => {
|
|
310
|
+
try {
|
|
311
|
+
const data = await collectResponseBody(res, config.maxResponseBytes, 'OAuth token response');
|
|
237
312
|
debug('Token exchange response received', {
|
|
238
313
|
statusCode: res.statusCode,
|
|
239
|
-
dataLength: data
|
|
314
|
+
dataLength: Buffer.byteLength(data)
|
|
240
315
|
});
|
|
241
316
|
|
|
242
317
|
// Check for error status codes
|
|
243
318
|
if (res.statusCode === 401) {
|
|
244
|
-
reject(
|
|
319
|
+
reject(createHttpError('Authentication failed: verify the configured API credentials', res.statusCode));
|
|
245
320
|
return;
|
|
246
321
|
}
|
|
247
322
|
if (res.statusCode === 403) {
|
|
248
|
-
|
|
249
|
-
reject(new Error('MCP access denied: Your API key does not have MCP enabled. Create a new MCP-enabled key in Tenant Settings → API Keys.'));
|
|
250
|
-
} else {
|
|
251
|
-
reject(new Error('Access forbidden: ' + data));
|
|
252
|
-
}
|
|
323
|
+
reject(createHttpError('MCP access denied for the configured API credentials', res.statusCode));
|
|
253
324
|
return;
|
|
254
325
|
}
|
|
255
326
|
if (res.statusCode !== 200) {
|
|
256
|
-
reject(
|
|
327
|
+
reject(createHttpError(`Token exchange failed (HTTP ${res.statusCode})`, res.statusCode));
|
|
257
328
|
return;
|
|
258
329
|
}
|
|
259
330
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
resolve(token);
|
|
267
|
-
} else if (json.error) {
|
|
268
|
-
reject(new Error(`OAuth error: ${json.error} - ${json.error_description || ''}`));
|
|
269
|
-
} else {
|
|
270
|
-
reject(new Error(`Token exchange failed: ${data}`));
|
|
271
|
-
}
|
|
272
|
-
} catch (e) {
|
|
273
|
-
reject(new Error(`Invalid JSON response: ${data}`));
|
|
274
|
-
}
|
|
275
|
-
});
|
|
331
|
+
const parsedToken = parseOAuthTokenResponse(data);
|
|
332
|
+
debug('Access token received successfully');
|
|
333
|
+
resolve(parsedToken);
|
|
334
|
+
} catch (responseError) {
|
|
335
|
+
reject(responseError);
|
|
336
|
+
}
|
|
276
337
|
});
|
|
277
338
|
|
|
278
|
-
req.
|
|
339
|
+
req.once('error', reject);
|
|
340
|
+
attachRequestTimeout(req, 'OAuth token request');
|
|
279
341
|
req.write(tokenData);
|
|
280
342
|
req.end();
|
|
281
343
|
});
|
|
@@ -286,8 +348,8 @@ async function getAccessToken() {
|
|
|
286
348
|
}
|
|
287
349
|
);
|
|
288
350
|
|
|
289
|
-
accessToken =
|
|
290
|
-
tokenExpiry = Date.now() + (
|
|
351
|
+
accessToken = tokenResponse.accessToken;
|
|
352
|
+
tokenExpiry = Date.now() + (tokenResponse.expiresInSeconds * 1000);
|
|
291
353
|
|
|
292
354
|
const duration = stopTimer();
|
|
293
355
|
recordOAuthAttempt(true, duration);
|
|
@@ -301,7 +363,11 @@ async function getAccessToken() {
|
|
|
301
363
|
} catch (err) {
|
|
302
364
|
const duration = stopTimer();
|
|
303
365
|
recordOAuthAttempt(false, duration);
|
|
304
|
-
error('OAuth flow failed',
|
|
366
|
+
error('OAuth flow failed', {
|
|
367
|
+
errorType: err?.name,
|
|
368
|
+
errorCode: err?.code,
|
|
369
|
+
statusCode: err?.statusCode
|
|
370
|
+
});
|
|
305
371
|
throw err;
|
|
306
372
|
}
|
|
307
373
|
}
|
|
@@ -329,8 +395,8 @@ function parseSSE(sseData) {
|
|
|
329
395
|
if (jsonData) {
|
|
330
396
|
try {
|
|
331
397
|
return JSON.parse(jsonData);
|
|
332
|
-
} catch
|
|
333
|
-
debug('SSE data parse error'
|
|
398
|
+
} catch {
|
|
399
|
+
debug('SSE data parse error');
|
|
334
400
|
return null;
|
|
335
401
|
}
|
|
336
402
|
}
|
|
@@ -338,9 +404,169 @@ function parseSSE(sseData) {
|
|
|
338
404
|
return null;
|
|
339
405
|
}
|
|
340
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Builds the error for a failed backend HTTP response. Keeps the backend's own
|
|
409
|
+
* JSON-RPC error or ProblemDetails message so the client sees why the call failed.
|
|
410
|
+
* @param {number} statusCode
|
|
411
|
+
* @param {string} responseData
|
|
412
|
+
* @returns {ProxyTransportError} error with statusCode, plus jsonRpcError when the backend sent one
|
|
413
|
+
*/
|
|
414
|
+
function createBackendHttpError(statusCode, responseData) {
|
|
415
|
+
let body = parseSSE(responseData);
|
|
416
|
+
if (!body) {
|
|
417
|
+
try {
|
|
418
|
+
body = JSON.parse(responseData);
|
|
419
|
+
} catch {
|
|
420
|
+
body = null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const rpcError = body?.error;
|
|
425
|
+
if (rpcError && Number.isInteger(rpcError.code) && typeof rpcError.message === 'string') {
|
|
426
|
+
const transportError = createHttpError(rpcError.message, statusCode);
|
|
427
|
+
transportError.jsonRpcError = rpcError;
|
|
428
|
+
return transportError;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const detail = [body?.detail, body?.title, body?.message]
|
|
432
|
+
.find(value => typeof value === 'string' && value.trim().length > 0);
|
|
433
|
+
if (detail) {
|
|
434
|
+
return createHttpError(
|
|
435
|
+
`MCP request failed (HTTP ${statusCode}): ${detail.trim().slice(0, MAX_FORWARDED_ERROR_LENGTH)}`,
|
|
436
|
+
statusCode
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return createHttpError(`MCP request failed (HTTP ${statusCode})`, statusCode);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Posts one JSON-RPC message to the backend /mcp endpoint with the given token.
|
|
445
|
+
* Each attempt has a total deadline: the socket `timeout` only fires on an idle
|
|
446
|
+
* socket, so a backend that keeps sending bytes (e.g. SSE keepalives) would
|
|
447
|
+
* otherwise hold the request open forever.
|
|
448
|
+
* @param {object} request - JSON-RPC 2.0 request object
|
|
449
|
+
* @param {string} requestId - Unique request ID for tracing
|
|
450
|
+
* @param {string} data - Serialized request body
|
|
451
|
+
* @param {string} token - OAuth access token
|
|
452
|
+
* @returns {Promise<object|null>} JSON-RPC 2.0 response object, or null for an acknowledged notification
|
|
453
|
+
*/
|
|
454
|
+
function postToBackend(request, requestId, data, token) {
|
|
455
|
+
return retryWithBackoff(
|
|
456
|
+
async () => {
|
|
457
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
458
|
+
let deadline;
|
|
459
|
+
const resolve = value => {
|
|
460
|
+
clearTimeout(deadline);
|
|
461
|
+
resolvePromise(value);
|
|
462
|
+
};
|
|
463
|
+
const reject = err => {
|
|
464
|
+
clearTimeout(deadline);
|
|
465
|
+
rejectPromise(err);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const req = https.request(new URL(config.mcpPath, config.baseUrl), {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
headers: {
|
|
471
|
+
'Authorization': `Bearer ${token}`,
|
|
472
|
+
'Content-Type': 'application/json',
|
|
473
|
+
'Accept': 'application/json, text/event-stream',
|
|
474
|
+
'Content-Length': Buffer.byteLength(data),
|
|
475
|
+
'X-Request-ID': requestId,
|
|
476
|
+
...buildMcpForwardHeaders(request)
|
|
477
|
+
},
|
|
478
|
+
// UTEAMUP-SEC-2026-05-02-008: see authorize handler above.
|
|
479
|
+
rejectUnauthorized: !config.insecureTLS,
|
|
480
|
+
timeout: config.requestTimeout
|
|
481
|
+
}, async (res) => {
|
|
482
|
+
try {
|
|
483
|
+
const responseData = await collectResponseBody(res, config.maxResponseBytes, 'MCP response');
|
|
484
|
+
trace('MCP response received', {
|
|
485
|
+
requestId,
|
|
486
|
+
statusCode: res.statusCode,
|
|
487
|
+
dataLength: Buffer.byteLength(responseData)
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (res.statusCode >= 400) {
|
|
491
|
+
reject(createBackendHttpError(res.statusCode, responseData));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Handle empty responses (e.g., HTTP 202 for notifications)
|
|
496
|
+
if (responseData.length === 0) {
|
|
497
|
+
if (request.id === undefined || request.id === null) {
|
|
498
|
+
trace('Notification acknowledged, no response needed', { requestId });
|
|
499
|
+
resolve(null);
|
|
500
|
+
} else {
|
|
501
|
+
debug('Empty response received for a request', { requestId });
|
|
502
|
+
resolve({
|
|
503
|
+
jsonrpc: '2.0',
|
|
504
|
+
error: { code: -32603, message: 'Backend returned an empty response' },
|
|
505
|
+
id: request.id
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Try parsing as SSE format first (text/event-stream)
|
|
512
|
+
if (responseData.includes('event:') || responseData.includes('data:')) {
|
|
513
|
+
debug('Detected SSE format, parsing...', { requestId });
|
|
514
|
+
const parsed = parseSSE(responseData);
|
|
515
|
+
if (parsed) {
|
|
516
|
+
trace('SSE parsed successfully', {
|
|
517
|
+
requestId,
|
|
518
|
+
responseId: parsed?.id,
|
|
519
|
+
hasError: Boolean(parsed?.error)
|
|
520
|
+
});
|
|
521
|
+
resolve(parsed);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Fall back to plain JSON parsing
|
|
527
|
+
try {
|
|
528
|
+
const parsed = JSON.parse(responseData);
|
|
529
|
+
trace('JSON parsed successfully', {
|
|
530
|
+
requestId,
|
|
531
|
+
responseId: parsed?.id,
|
|
532
|
+
hasError: Boolean(parsed?.error)
|
|
533
|
+
});
|
|
534
|
+
resolve(parsed);
|
|
535
|
+
} catch {
|
|
536
|
+
debug('Parse error (not SSE, not JSON)', { requestId });
|
|
537
|
+
resolve({
|
|
538
|
+
jsonrpc: '2.0',
|
|
539
|
+
error: { code: -32700, message: 'Backend returned an invalid response' },
|
|
540
|
+
id: request.id
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
} catch (responseError) {
|
|
544
|
+
reject(responseError);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
req.once('error', reject);
|
|
549
|
+
attachRequestTimeout(req, 'MCP request');
|
|
550
|
+
deadline = setTimeout(() => {
|
|
551
|
+
reject(new ProxyTransportError(`Backend did not answer within ${config.requestTimeout} ms`));
|
|
552
|
+
req.destroy();
|
|
553
|
+
}, config.requestTimeout);
|
|
554
|
+
req.write(data);
|
|
555
|
+
req.end();
|
|
556
|
+
});
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
maxRetries: getMcpRetryLimit(request.method, config.maxRetries),
|
|
560
|
+
operationName: `MCP request [${request.method}]`
|
|
561
|
+
}
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
341
565
|
/**
|
|
342
566
|
* Forwards a JSON-RPC 2.0 MCP request to the backend.
|
|
343
567
|
* Handles both plain JSON and Server-Sent Events (SSE) response formats.
|
|
568
|
+
* A 401 means the backend rejected the token before running anything, so the
|
|
569
|
+
* request is replayed once with a fresh token (safe even for tools/call).
|
|
344
570
|
* @param {object} request - JSON-RPC 2.0 request object
|
|
345
571
|
* @param {string} requestId - Unique request ID for tracing
|
|
346
572
|
* @returns {Promise<object>} JSON-RPC 2.0 response object
|
|
@@ -349,91 +575,37 @@ async function forwardRequest(request, requestId) {
|
|
|
349
575
|
const stopTimer = startTimer(`request_${request.method}`);
|
|
350
576
|
|
|
351
577
|
try {
|
|
352
|
-
const token = await getAccessToken();
|
|
353
578
|
const data = JSON.stringify(request);
|
|
579
|
+
const requestBytes = Buffer.byteLength(data);
|
|
580
|
+
if (requestBytes > config.maxRequestBytes) {
|
|
581
|
+
throw new ProxyTransportError('Request exceeds the configured size limit');
|
|
582
|
+
}
|
|
354
583
|
|
|
355
584
|
trace('Forwarding MCP request', {
|
|
356
585
|
requestId,
|
|
357
586
|
method: request.method,
|
|
358
587
|
id: request.id,
|
|
359
|
-
bodyLength:
|
|
588
|
+
bodyLength: requestBytes
|
|
360
589
|
});
|
|
361
590
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
'Authorization': `Bearer ${token}`,
|
|
369
|
-
'Content-Type': 'application/json',
|
|
370
|
-
'Accept': 'application/json, text/event-stream',
|
|
371
|
-
'Content-Length': Buffer.byteLength(data),
|
|
372
|
-
'X-Request-ID': requestId
|
|
373
|
-
},
|
|
374
|
-
rejectUnauthorized: false,
|
|
375
|
-
timeout: config.requestTimeout
|
|
376
|
-
}, (res) => {
|
|
377
|
-
let responseData = '';
|
|
378
|
-
res.on('data', chunk => responseData += chunk);
|
|
379
|
-
res.on('end', () => {
|
|
380
|
-
trace('MCP response received', {
|
|
381
|
-
requestId,
|
|
382
|
-
statusCode: res.statusCode,
|
|
383
|
-
dataLength: responseData.length
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// Handle empty responses (e.g., HTTP 202 for notifications)
|
|
387
|
-
if (responseData.length === 0) {
|
|
388
|
-
debug('Empty response received (likely notification acknowledged)', { requestId });
|
|
389
|
-
// For notifications (no id), don't send a response
|
|
390
|
-
if (request.id === undefined || request.id === null) {
|
|
391
|
-
trace('Notification acknowledged, no response needed', { requestId });
|
|
392
|
-
resolve(null); // Don't send anything back for notifications
|
|
393
|
-
} else {
|
|
394
|
-
// For requests with ID, return success
|
|
395
|
-
resolve({ jsonrpc: '2.0', result: {}, id: request.id });
|
|
396
|
-
}
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Try parsing as SSE format first (text/event-stream)
|
|
401
|
-
if (responseData.includes('event:') || responseData.includes('data:')) {
|
|
402
|
-
debug('Detected SSE format, parsing...', { requestId });
|
|
403
|
-
const parsed = parseSSE(responseData);
|
|
404
|
-
if (parsed) {
|
|
405
|
-
trace('SSE parsed successfully', { requestId, result: parsed });
|
|
406
|
-
resolve(parsed);
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Fall back to plain JSON parsing
|
|
412
|
-
try {
|
|
413
|
-
const parsed = JSON.parse(responseData);
|
|
414
|
-
trace('JSON parsed successfully', { requestId, result: parsed });
|
|
415
|
-
resolve(parsed);
|
|
416
|
-
} catch (e) {
|
|
417
|
-
debug('Parse error (not SSE, not JSON)', { requestId, error: e.message });
|
|
418
|
-
resolve({
|
|
419
|
-
jsonrpc: '2.0',
|
|
420
|
-
error: { code: -32700, message: 'Parse error', data: responseData },
|
|
421
|
-
id: request.id
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
});
|
|
425
|
-
});
|
|
591
|
+
let token = await getAccessToken();
|
|
592
|
+
let response;
|
|
593
|
+
try {
|
|
594
|
+
response = await postToBackend(request, requestId, data, token);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
if (err?.statusCode !== 401) throw err;
|
|
426
597
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
{
|
|
433
|
-
|
|
434
|
-
|
|
598
|
+
info('Backend rejected the access token, re-authenticating once', { requestId });
|
|
599
|
+
clearAccessToken(token);
|
|
600
|
+
token = await getAccessToken();
|
|
601
|
+
try {
|
|
602
|
+
response = await postToBackend(request, requestId, data, token);
|
|
603
|
+
} catch (retryErr) {
|
|
604
|
+
if (retryErr?.statusCode !== 401) throw retryErr;
|
|
605
|
+
clearAccessToken(token);
|
|
606
|
+
throw createHttpError('Authentication failed: the backend rejected the access token', 401);
|
|
435
607
|
}
|
|
436
|
-
|
|
608
|
+
}
|
|
437
609
|
|
|
438
610
|
const duration = stopTimer();
|
|
439
611
|
recordRequest(true, duration, request.method);
|
|
@@ -445,7 +617,9 @@ async function forwardRequest(request, requestId) {
|
|
|
445
617
|
error('Request forwarding failed', {
|
|
446
618
|
requestId,
|
|
447
619
|
method: request.method,
|
|
448
|
-
|
|
620
|
+
errorType: err?.name,
|
|
621
|
+
errorCode: err?.code,
|
|
622
|
+
statusCode: err?.statusCode
|
|
449
623
|
});
|
|
450
624
|
throw err;
|
|
451
625
|
}
|
|
@@ -460,20 +634,19 @@ if (args.includes('--test-auth')) {
|
|
|
460
634
|
|
|
461
635
|
try {
|
|
462
636
|
const stopTimer = startTimer('test_auth');
|
|
463
|
-
|
|
637
|
+
await getAccessToken();
|
|
464
638
|
const duration = stopTimer();
|
|
465
639
|
|
|
466
640
|
console.error('');
|
|
467
641
|
console.error('✅ Authentication successful!');
|
|
468
642
|
console.error(` Token obtained in ${duration}ms`);
|
|
469
|
-
console.error(` Token prefix: ${token.substring(0, 20)}...`);
|
|
470
643
|
console.error(` Token expires: ${new Date(tokenExpiry).toISOString()}`);
|
|
471
644
|
printMetrics();
|
|
472
645
|
process.exit(0);
|
|
473
646
|
} catch (err) {
|
|
474
647
|
console.error('');
|
|
475
648
|
console.error('❌ Authentication failed!');
|
|
476
|
-
console.error(` Error: ${err
|
|
649
|
+
console.error(` Error: ${toPublicErrorMessage(err)}`);
|
|
477
650
|
process.exit(1);
|
|
478
651
|
}
|
|
479
652
|
})();
|
|
@@ -491,79 +664,123 @@ async function main() {
|
|
|
491
664
|
|
|
492
665
|
printConfigSummary(config);
|
|
493
666
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
667
|
+
const lineBuffer = new BoundedLineBuffer(config.maxRequestBytes);
|
|
668
|
+
// Requests run concurrently so one slow tools/call cannot block the rest.
|
|
669
|
+
// Each response is one stdout.write call, so messages never interleave.
|
|
670
|
+
const inFlight = new Set();
|
|
671
|
+
|
|
672
|
+
const writeErrorResponse = (code, message, id = null) => {
|
|
673
|
+
process.stdout.write(JSON.stringify({
|
|
674
|
+
jsonrpc: '2.0',
|
|
675
|
+
error: { code, message },
|
|
676
|
+
id
|
|
677
|
+
}) + '\n');
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const processInputEvent = async inputEvent => {
|
|
681
|
+
if (inputEvent.oversized) {
|
|
682
|
+
writeErrorResponse(-32600, 'Request exceeds the configured size limit');
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
503
685
|
|
|
504
|
-
|
|
686
|
+
const line = inputEvent.line.trim();
|
|
687
|
+
if (!line) return;
|
|
505
688
|
|
|
506
|
-
|
|
689
|
+
const requestId = generateRequestId();
|
|
690
|
+
let request;
|
|
691
|
+
try {
|
|
692
|
+
request = JSON.parse(line);
|
|
693
|
+
} catch {
|
|
694
|
+
writeErrorResponse(-32700, 'Parse error');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
507
697
|
|
|
508
|
-
|
|
509
|
-
|
|
698
|
+
if (
|
|
699
|
+
request === null ||
|
|
700
|
+
Array.isArray(request) ||
|
|
701
|
+
typeof request !== 'object' ||
|
|
702
|
+
request.jsonrpc !== '2.0' ||
|
|
703
|
+
typeof request.method !== 'string' ||
|
|
704
|
+
request.method.length === 0
|
|
705
|
+
) {
|
|
706
|
+
writeErrorResponse(-32600, 'Invalid Request', request?.id ?? null);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
510
709
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
710
|
+
// Liveness check of this proxy: needs neither a token nor the backend.
|
|
711
|
+
if (request.method === 'ping') {
|
|
712
|
+
if (request.id !== undefined && request.id !== null) {
|
|
713
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: {} }) + '\n');
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
516
717
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
trace('Response sent', { requestId, responseId: request.id });
|
|
532
|
-
} catch (err) {
|
|
533
|
-
error('Request processing error', {
|
|
534
|
-
requestId,
|
|
535
|
-
error: err.message
|
|
536
|
-
});
|
|
718
|
+
try {
|
|
719
|
+
info('Processing request', {
|
|
720
|
+
requestId,
|
|
721
|
+
method: request.method,
|
|
722
|
+
id: request.id
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const response = await forwardRequest(request, requestId);
|
|
726
|
+
|
|
727
|
+
// For notifications (null response), don't write anything to stdout.
|
|
728
|
+
if (response === null) {
|
|
729
|
+
debug('Notification handled, no response sent', { requestId });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
537
732
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
requestIdFromLine = req.id;
|
|
543
|
-
} catch (e) {
|
|
544
|
-
// Line wasn't valid JSON, use null
|
|
545
|
-
}
|
|
733
|
+
// Ensure response includes request ID for proper JSON-RPC 2.0 format.
|
|
734
|
+
if (request.id !== undefined && response.id === undefined) {
|
|
735
|
+
response.id = request.id;
|
|
736
|
+
}
|
|
546
737
|
|
|
547
|
-
|
|
738
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
739
|
+
trace('Response sent', { requestId, responseId: request.id });
|
|
740
|
+
} catch (err) {
|
|
741
|
+
error('Request processing error', {
|
|
742
|
+
requestId,
|
|
743
|
+
errorType: err?.name,
|
|
744
|
+
errorCode: err?.code,
|
|
745
|
+
statusCode: err?.statusCode
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
if (err?.jsonRpcError) {
|
|
749
|
+
process.stdout.write(JSON.stringify({
|
|
548
750
|
jsonrpc: '2.0',
|
|
549
|
-
error:
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
id: requestIdFromLine
|
|
555
|
-
};
|
|
556
|
-
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
751
|
+
error: err.jsonRpcError,
|
|
752
|
+
id: request.id ?? null
|
|
753
|
+
}) + '\n');
|
|
754
|
+
} else {
|
|
755
|
+
writeErrorResponse(-32603, toPublicErrorMessage(err), request.id ?? null);
|
|
557
756
|
}
|
|
558
757
|
}
|
|
559
|
-
}
|
|
758
|
+
};
|
|
560
759
|
|
|
561
|
-
process.stdin.on('
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
760
|
+
process.stdin.on('data', chunk => {
|
|
761
|
+
for (const inputEvent of lineBuffer.push(chunk)) {
|
|
762
|
+
const task = processInputEvent(inputEvent).finally(() => inFlight.delete(task));
|
|
763
|
+
inFlight.add(task);
|
|
764
|
+
}
|
|
565
765
|
});
|
|
566
766
|
|
|
767
|
+
// The client closing stdin means it is gone (e.g. a reconnect started a new
|
|
768
|
+
// proxy). Give in-flight requests a short grace period, then exit even if one
|
|
769
|
+
// is stuck, so this process never outlives its client.
|
|
770
|
+
let exiting = false;
|
|
771
|
+
const exitAfterInFlight = () => {
|
|
772
|
+
if (exiting) return;
|
|
773
|
+
exiting = true;
|
|
774
|
+
info('stdin closed, exiting', { inFlight: inFlight.size, graceMs: STDIN_CLOSE_GRACE_MS });
|
|
775
|
+
const grace = new Promise(resolve => setTimeout(resolve, STDIN_CLOSE_GRACE_MS));
|
|
776
|
+
Promise.race([Promise.allSettled([...inFlight]), grace]).then(() => {
|
|
777
|
+
printMetrics();
|
|
778
|
+
process.exit(0);
|
|
779
|
+
});
|
|
780
|
+
};
|
|
781
|
+
process.stdin.on('end', exitAfterInFlight);
|
|
782
|
+
process.stdin.on('close', exitAfterInFlight);
|
|
783
|
+
|
|
567
784
|
process.stdin.resume();
|
|
568
785
|
}
|
|
569
786
|
|
|
@@ -581,11 +798,17 @@ process.on('SIGTERM', () => {
|
|
|
581
798
|
});
|
|
582
799
|
|
|
583
800
|
process.on('uncaughtException', (err) => {
|
|
584
|
-
error('Uncaught exception',
|
|
801
|
+
error('Uncaught exception', {
|
|
802
|
+
errorType: err?.name,
|
|
803
|
+
errorCode: err?.code
|
|
804
|
+
});
|
|
585
805
|
process.exit(1);
|
|
586
806
|
});
|
|
587
807
|
|
|
588
808
|
main().catch((err) => {
|
|
589
|
-
error('Fatal error',
|
|
809
|
+
error('Fatal error', {
|
|
810
|
+
errorType: err?.name,
|
|
811
|
+
errorCode: err?.code
|
|
812
|
+
});
|
|
590
813
|
process.exit(1);
|
|
591
814
|
});
|