@uteamup/mcp-server 1.0.0-beta.8 → 2.3.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 +631 -0
- package/README.md +77 -0
- package/assets/logo.png +0 -0
- package/assets/logo.svg +11 -0
- package/index.js +426 -205
- package/lib/config.js +60 -5
- package/lib/logger.js +56 -3
- 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 +27 -13
- package/server.json +77 -0
package/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* UteamUP MCP Proxy - Production-ready OAuth handler with enhanced debugging
|
|
5
|
-
* Version: 1.0.0-beta.
|
|
5
|
+
* Version: 1.0.0-beta.9
|
|
6
6
|
*
|
|
7
7
|
* Features:
|
|
8
8
|
* - OAuth 2.0 Authorization Code + PKCE authentication
|
|
@@ -16,13 +16,23 @@
|
|
|
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
|
-
const VERSION = '1.0.0-beta.
|
|
35
|
+
const VERSION = '1.0.0-beta.9';
|
|
26
36
|
|
|
27
37
|
// CLI argument handling
|
|
28
38
|
const args = process.argv.slice(2);
|
|
@@ -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,64 @@ 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
283
|
// Step 2: Exchange for access token with retry
|
|
211
284
|
const tokenData = new URLSearchParams({
|
|
212
285
|
grant_type: 'authorization_code',
|
|
213
286
|
code: authCode,
|
|
214
|
-
redirect_uri:
|
|
287
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
215
288
|
client_id: config.apiKey,
|
|
216
289
|
client_secret: config.secret,
|
|
217
290
|
code_verifier: verifier
|
|
218
291
|
}).toString();
|
|
219
292
|
|
|
220
|
-
const
|
|
293
|
+
const tokenResponse = await retryWithBackoff(
|
|
221
294
|
async () => {
|
|
222
295
|
return new Promise((resolve, reject) => {
|
|
223
296
|
trace('Exchanging authorization code for access token');
|
|
224
297
|
|
|
225
|
-
const req = https.request(
|
|
298
|
+
const req = https.request(new URL('/oauth/token', config.baseUrl), {
|
|
226
299
|
method: 'POST',
|
|
227
300
|
headers: {
|
|
228
301
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
229
302
|
'Content-Length': Buffer.byteLength(tokenData)
|
|
230
303
|
},
|
|
231
|
-
|
|
304
|
+
// UTEAMUP-SEC-2026-05-02-008: see authorize handler above.
|
|
305
|
+
rejectUnauthorized: !config.insecureTLS,
|
|
232
306
|
timeout: config.requestTimeout
|
|
233
|
-
}, (res) => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
res.on('end', () => {
|
|
307
|
+
}, async (res) => {
|
|
308
|
+
try {
|
|
309
|
+
const data = await collectResponseBody(res, config.maxResponseBytes, 'OAuth token response');
|
|
237
310
|
debug('Token exchange response received', {
|
|
238
311
|
statusCode: res.statusCode,
|
|
239
|
-
dataLength: data
|
|
312
|
+
dataLength: Buffer.byteLength(data)
|
|
240
313
|
});
|
|
241
314
|
|
|
242
315
|
// Check for error status codes
|
|
243
316
|
if (res.statusCode === 401) {
|
|
244
|
-
reject(
|
|
317
|
+
reject(createHttpError('Authentication failed: verify the configured API credentials', res.statusCode));
|
|
245
318
|
return;
|
|
246
319
|
}
|
|
247
320
|
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
|
-
}
|
|
321
|
+
reject(createHttpError('MCP access denied for the configured API credentials', res.statusCode));
|
|
253
322
|
return;
|
|
254
323
|
}
|
|
255
324
|
if (res.statusCode !== 200) {
|
|
256
|
-
reject(
|
|
325
|
+
reject(createHttpError(`Token exchange failed (HTTP ${res.statusCode})`, res.statusCode));
|
|
257
326
|
return;
|
|
258
327
|
}
|
|
259
328
|
|
|
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
|
-
});
|
|
329
|
+
const parsedToken = parseOAuthTokenResponse(data);
|
|
330
|
+
debug('Access token received successfully');
|
|
331
|
+
resolve(parsedToken);
|
|
332
|
+
} catch (responseError) {
|
|
333
|
+
reject(responseError);
|
|
334
|
+
}
|
|
276
335
|
});
|
|
277
336
|
|
|
278
|
-
req.
|
|
337
|
+
req.once('error', reject);
|
|
338
|
+
attachRequestTimeout(req, 'OAuth token request');
|
|
279
339
|
req.write(tokenData);
|
|
280
340
|
req.end();
|
|
281
341
|
});
|
|
@@ -286,8 +346,8 @@ async function getAccessToken() {
|
|
|
286
346
|
}
|
|
287
347
|
);
|
|
288
348
|
|
|
289
|
-
accessToken =
|
|
290
|
-
tokenExpiry = Date.now() + (
|
|
349
|
+
accessToken = tokenResponse.accessToken;
|
|
350
|
+
tokenExpiry = Date.now() + (tokenResponse.expiresInSeconds * 1000);
|
|
291
351
|
|
|
292
352
|
const duration = stopTimer();
|
|
293
353
|
recordOAuthAttempt(true, duration);
|
|
@@ -301,7 +361,11 @@ async function getAccessToken() {
|
|
|
301
361
|
} catch (err) {
|
|
302
362
|
const duration = stopTimer();
|
|
303
363
|
recordOAuthAttempt(false, duration);
|
|
304
|
-
error('OAuth flow failed',
|
|
364
|
+
error('OAuth flow failed', {
|
|
365
|
+
errorType: err?.name,
|
|
366
|
+
errorCode: err?.code,
|
|
367
|
+
statusCode: err?.statusCode
|
|
368
|
+
});
|
|
305
369
|
throw err;
|
|
306
370
|
}
|
|
307
371
|
}
|
|
@@ -329,8 +393,8 @@ function parseSSE(sseData) {
|
|
|
329
393
|
if (jsonData) {
|
|
330
394
|
try {
|
|
331
395
|
return JSON.parse(jsonData);
|
|
332
|
-
} catch
|
|
333
|
-
debug('SSE data parse error'
|
|
396
|
+
} catch {
|
|
397
|
+
debug('SSE data parse error');
|
|
334
398
|
return null;
|
|
335
399
|
}
|
|
336
400
|
}
|
|
@@ -338,9 +402,169 @@ function parseSSE(sseData) {
|
|
|
338
402
|
return null;
|
|
339
403
|
}
|
|
340
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Builds the error for a failed backend HTTP response. Keeps the backend's own
|
|
407
|
+
* JSON-RPC error or ProblemDetails message so the client sees why the call failed.
|
|
408
|
+
* @param {number} statusCode
|
|
409
|
+
* @param {string} responseData
|
|
410
|
+
* @returns {ProxyTransportError} error with statusCode, plus jsonRpcError when the backend sent one
|
|
411
|
+
*/
|
|
412
|
+
function createBackendHttpError(statusCode, responseData) {
|
|
413
|
+
let body = parseSSE(responseData);
|
|
414
|
+
if (!body) {
|
|
415
|
+
try {
|
|
416
|
+
body = JSON.parse(responseData);
|
|
417
|
+
} catch {
|
|
418
|
+
body = null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const rpcError = body?.error;
|
|
423
|
+
if (rpcError && Number.isInteger(rpcError.code) && typeof rpcError.message === 'string') {
|
|
424
|
+
const transportError = createHttpError(rpcError.message, statusCode);
|
|
425
|
+
transportError.jsonRpcError = rpcError;
|
|
426
|
+
return transportError;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const detail = [body?.detail, body?.title, body?.message]
|
|
430
|
+
.find(value => typeof value === 'string' && value.trim().length > 0);
|
|
431
|
+
if (detail) {
|
|
432
|
+
return createHttpError(
|
|
433
|
+
`MCP request failed (HTTP ${statusCode}): ${detail.trim().slice(0, MAX_FORWARDED_ERROR_LENGTH)}`,
|
|
434
|
+
statusCode
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return createHttpError(`MCP request failed (HTTP ${statusCode})`, statusCode);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Posts one JSON-RPC message to the backend /mcp endpoint with the given token.
|
|
443
|
+
* Each attempt has a total deadline: the socket `timeout` only fires on an idle
|
|
444
|
+
* socket, so a backend that keeps sending bytes (e.g. SSE keepalives) would
|
|
445
|
+
* otherwise hold the request open forever.
|
|
446
|
+
* @param {object} request - JSON-RPC 2.0 request object
|
|
447
|
+
* @param {string} requestId - Unique request ID for tracing
|
|
448
|
+
* @param {string} data - Serialized request body
|
|
449
|
+
* @param {string} token - OAuth access token
|
|
450
|
+
* @returns {Promise<object|null>} JSON-RPC 2.0 response object, or null for an acknowledged notification
|
|
451
|
+
*/
|
|
452
|
+
function postToBackend(request, requestId, data, token) {
|
|
453
|
+
return retryWithBackoff(
|
|
454
|
+
async () => {
|
|
455
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
456
|
+
let deadline;
|
|
457
|
+
const resolve = value => {
|
|
458
|
+
clearTimeout(deadline);
|
|
459
|
+
resolvePromise(value);
|
|
460
|
+
};
|
|
461
|
+
const reject = err => {
|
|
462
|
+
clearTimeout(deadline);
|
|
463
|
+
rejectPromise(err);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const req = https.request(new URL(config.mcpPath, config.baseUrl), {
|
|
467
|
+
method: 'POST',
|
|
468
|
+
headers: {
|
|
469
|
+
'Authorization': `Bearer ${token}`,
|
|
470
|
+
'Content-Type': 'application/json',
|
|
471
|
+
'Accept': 'application/json, text/event-stream',
|
|
472
|
+
'Content-Length': Buffer.byteLength(data),
|
|
473
|
+
'X-Request-ID': requestId,
|
|
474
|
+
...buildMcpForwardHeaders(request)
|
|
475
|
+
},
|
|
476
|
+
// UTEAMUP-SEC-2026-05-02-008: see authorize handler above.
|
|
477
|
+
rejectUnauthorized: !config.insecureTLS,
|
|
478
|
+
timeout: config.requestTimeout
|
|
479
|
+
}, async (res) => {
|
|
480
|
+
try {
|
|
481
|
+
const responseData = await collectResponseBody(res, config.maxResponseBytes, 'MCP response');
|
|
482
|
+
trace('MCP response received', {
|
|
483
|
+
requestId,
|
|
484
|
+
statusCode: res.statusCode,
|
|
485
|
+
dataLength: Buffer.byteLength(responseData)
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
if (res.statusCode >= 400) {
|
|
489
|
+
reject(createBackendHttpError(res.statusCode, responseData));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Handle empty responses (e.g., HTTP 202 for notifications)
|
|
494
|
+
if (responseData.length === 0) {
|
|
495
|
+
if (request.id === undefined || request.id === null) {
|
|
496
|
+
trace('Notification acknowledged, no response needed', { requestId });
|
|
497
|
+
resolve(null);
|
|
498
|
+
} else {
|
|
499
|
+
debug('Empty response received for a request', { requestId });
|
|
500
|
+
resolve({
|
|
501
|
+
jsonrpc: '2.0',
|
|
502
|
+
error: { code: -32603, message: 'Backend returned an empty response' },
|
|
503
|
+
id: request.id
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Try parsing as SSE format first (text/event-stream)
|
|
510
|
+
if (responseData.includes('event:') || responseData.includes('data:')) {
|
|
511
|
+
debug('Detected SSE format, parsing...', { requestId });
|
|
512
|
+
const parsed = parseSSE(responseData);
|
|
513
|
+
if (parsed) {
|
|
514
|
+
trace('SSE parsed successfully', {
|
|
515
|
+
requestId,
|
|
516
|
+
responseId: parsed?.id,
|
|
517
|
+
hasError: Boolean(parsed?.error)
|
|
518
|
+
});
|
|
519
|
+
resolve(parsed);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Fall back to plain JSON parsing
|
|
525
|
+
try {
|
|
526
|
+
const parsed = JSON.parse(responseData);
|
|
527
|
+
trace('JSON parsed successfully', {
|
|
528
|
+
requestId,
|
|
529
|
+
responseId: parsed?.id,
|
|
530
|
+
hasError: Boolean(parsed?.error)
|
|
531
|
+
});
|
|
532
|
+
resolve(parsed);
|
|
533
|
+
} catch {
|
|
534
|
+
debug('Parse error (not SSE, not JSON)', { requestId });
|
|
535
|
+
resolve({
|
|
536
|
+
jsonrpc: '2.0',
|
|
537
|
+
error: { code: -32700, message: 'Backend returned an invalid response' },
|
|
538
|
+
id: request.id
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
} catch (responseError) {
|
|
542
|
+
reject(responseError);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
req.once('error', reject);
|
|
547
|
+
attachRequestTimeout(req, 'MCP request');
|
|
548
|
+
deadline = setTimeout(() => {
|
|
549
|
+
reject(new ProxyTransportError(`Backend did not answer within ${config.requestTimeout} ms`));
|
|
550
|
+
req.destroy();
|
|
551
|
+
}, config.requestTimeout);
|
|
552
|
+
req.write(data);
|
|
553
|
+
req.end();
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
maxRetries: getMcpRetryLimit(request.method, config.maxRetries),
|
|
558
|
+
operationName: `MCP request [${request.method}]`
|
|
559
|
+
}
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
341
563
|
/**
|
|
342
564
|
* Forwards a JSON-RPC 2.0 MCP request to the backend.
|
|
343
565
|
* Handles both plain JSON and Server-Sent Events (SSE) response formats.
|
|
566
|
+
* A 401 means the backend rejected the token before running anything, so the
|
|
567
|
+
* request is replayed once with a fresh token (safe even for tools/call).
|
|
344
568
|
* @param {object} request - JSON-RPC 2.0 request object
|
|
345
569
|
* @param {string} requestId - Unique request ID for tracing
|
|
346
570
|
* @returns {Promise<object>} JSON-RPC 2.0 response object
|
|
@@ -349,91 +573,37 @@ async function forwardRequest(request, requestId) {
|
|
|
349
573
|
const stopTimer = startTimer(`request_${request.method}`);
|
|
350
574
|
|
|
351
575
|
try {
|
|
352
|
-
const token = await getAccessToken();
|
|
353
576
|
const data = JSON.stringify(request);
|
|
577
|
+
const requestBytes = Buffer.byteLength(data);
|
|
578
|
+
if (requestBytes > config.maxRequestBytes) {
|
|
579
|
+
throw new ProxyTransportError('Request exceeds the configured size limit');
|
|
580
|
+
}
|
|
354
581
|
|
|
355
582
|
trace('Forwarding MCP request', {
|
|
356
583
|
requestId,
|
|
357
584
|
method: request.method,
|
|
358
585
|
id: request.id,
|
|
359
|
-
bodyLength:
|
|
586
|
+
bodyLength: requestBytes
|
|
360
587
|
});
|
|
361
588
|
|
|
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
|
-
});
|
|
589
|
+
let token = await getAccessToken();
|
|
590
|
+
let response;
|
|
591
|
+
try {
|
|
592
|
+
response = await postToBackend(request, requestId, data, token);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
if (err?.statusCode !== 401) throw err;
|
|
426
595
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
{
|
|
433
|
-
|
|
434
|
-
|
|
596
|
+
info('Backend rejected the access token, re-authenticating once', { requestId });
|
|
597
|
+
clearAccessToken(token);
|
|
598
|
+
token = await getAccessToken();
|
|
599
|
+
try {
|
|
600
|
+
response = await postToBackend(request, requestId, data, token);
|
|
601
|
+
} catch (retryErr) {
|
|
602
|
+
if (retryErr?.statusCode !== 401) throw retryErr;
|
|
603
|
+
clearAccessToken(token);
|
|
604
|
+
throw createHttpError('Authentication failed: the backend rejected the access token', 401);
|
|
435
605
|
}
|
|
436
|
-
|
|
606
|
+
}
|
|
437
607
|
|
|
438
608
|
const duration = stopTimer();
|
|
439
609
|
recordRequest(true, duration, request.method);
|
|
@@ -445,7 +615,9 @@ async function forwardRequest(request, requestId) {
|
|
|
445
615
|
error('Request forwarding failed', {
|
|
446
616
|
requestId,
|
|
447
617
|
method: request.method,
|
|
448
|
-
|
|
618
|
+
errorType: err?.name,
|
|
619
|
+
errorCode: err?.code,
|
|
620
|
+
statusCode: err?.statusCode
|
|
449
621
|
});
|
|
450
622
|
throw err;
|
|
451
623
|
}
|
|
@@ -460,20 +632,19 @@ if (args.includes('--test-auth')) {
|
|
|
460
632
|
|
|
461
633
|
try {
|
|
462
634
|
const stopTimer = startTimer('test_auth');
|
|
463
|
-
|
|
635
|
+
await getAccessToken();
|
|
464
636
|
const duration = stopTimer();
|
|
465
637
|
|
|
466
638
|
console.error('');
|
|
467
639
|
console.error('✅ Authentication successful!');
|
|
468
640
|
console.error(` Token obtained in ${duration}ms`);
|
|
469
|
-
console.error(` Token prefix: ${token.substring(0, 20)}...`);
|
|
470
641
|
console.error(` Token expires: ${new Date(tokenExpiry).toISOString()}`);
|
|
471
642
|
printMetrics();
|
|
472
643
|
process.exit(0);
|
|
473
644
|
} catch (err) {
|
|
474
645
|
console.error('');
|
|
475
646
|
console.error('❌ Authentication failed!');
|
|
476
|
-
console.error(` Error: ${err
|
|
647
|
+
console.error(` Error: ${toPublicErrorMessage(err)}`);
|
|
477
648
|
process.exit(1);
|
|
478
649
|
}
|
|
479
650
|
})();
|
|
@@ -491,79 +662,123 @@ async function main() {
|
|
|
491
662
|
|
|
492
663
|
printConfigSummary(config);
|
|
493
664
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
665
|
+
const lineBuffer = new BoundedLineBuffer(config.maxRequestBytes);
|
|
666
|
+
// Requests run concurrently so one slow tools/call cannot block the rest.
|
|
667
|
+
// Each response is one stdout.write call, so messages never interleave.
|
|
668
|
+
const inFlight = new Set();
|
|
669
|
+
|
|
670
|
+
const writeErrorResponse = (code, message, id = null) => {
|
|
671
|
+
process.stdout.write(JSON.stringify({
|
|
672
|
+
jsonrpc: '2.0',
|
|
673
|
+
error: { code, message },
|
|
674
|
+
id
|
|
675
|
+
}) + '\n');
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const processInputEvent = async inputEvent => {
|
|
679
|
+
if (inputEvent.oversized) {
|
|
680
|
+
writeErrorResponse(-32600, 'Request exceeds the configured size limit');
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
503
683
|
|
|
504
|
-
|
|
684
|
+
const line = inputEvent.line.trim();
|
|
685
|
+
if (!line) return;
|
|
505
686
|
|
|
506
|
-
|
|
687
|
+
const requestId = generateRequestId();
|
|
688
|
+
let request;
|
|
689
|
+
try {
|
|
690
|
+
request = JSON.parse(line);
|
|
691
|
+
} catch {
|
|
692
|
+
writeErrorResponse(-32700, 'Parse error');
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
507
695
|
|
|
508
|
-
|
|
509
|
-
|
|
696
|
+
if (
|
|
697
|
+
request === null ||
|
|
698
|
+
Array.isArray(request) ||
|
|
699
|
+
typeof request !== 'object' ||
|
|
700
|
+
request.jsonrpc !== '2.0' ||
|
|
701
|
+
typeof request.method !== 'string' ||
|
|
702
|
+
request.method.length === 0
|
|
703
|
+
) {
|
|
704
|
+
writeErrorResponse(-32600, 'Invalid Request', request?.id ?? null);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
510
707
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
708
|
+
// Liveness check of this proxy: needs neither a token nor the backend.
|
|
709
|
+
if (request.method === 'ping') {
|
|
710
|
+
if (request.id !== undefined && request.id !== null) {
|
|
711
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: {} }) + '\n');
|
|
712
|
+
}
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
516
715
|
|
|
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
|
-
});
|
|
716
|
+
try {
|
|
717
|
+
info('Processing request', {
|
|
718
|
+
requestId,
|
|
719
|
+
method: request.method,
|
|
720
|
+
id: request.id
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const response = await forwardRequest(request, requestId);
|
|
724
|
+
|
|
725
|
+
// For notifications (null response), don't write anything to stdout.
|
|
726
|
+
if (response === null) {
|
|
727
|
+
debug('Notification handled, no response sent', { requestId });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
537
730
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
requestIdFromLine = req.id;
|
|
543
|
-
} catch (e) {
|
|
544
|
-
// Line wasn't valid JSON, use null
|
|
545
|
-
}
|
|
731
|
+
// Ensure response includes request ID for proper JSON-RPC 2.0 format.
|
|
732
|
+
if (request.id !== undefined && response.id === undefined) {
|
|
733
|
+
response.id = request.id;
|
|
734
|
+
}
|
|
546
735
|
|
|
547
|
-
|
|
736
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
737
|
+
trace('Response sent', { requestId, responseId: request.id });
|
|
738
|
+
} catch (err) {
|
|
739
|
+
error('Request processing error', {
|
|
740
|
+
requestId,
|
|
741
|
+
errorType: err?.name,
|
|
742
|
+
errorCode: err?.code,
|
|
743
|
+
statusCode: err?.statusCode
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
if (err?.jsonRpcError) {
|
|
747
|
+
process.stdout.write(JSON.stringify({
|
|
548
748
|
jsonrpc: '2.0',
|
|
549
|
-
error:
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
id: requestIdFromLine
|
|
555
|
-
};
|
|
556
|
-
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
749
|
+
error: err.jsonRpcError,
|
|
750
|
+
id: request.id ?? null
|
|
751
|
+
}) + '\n');
|
|
752
|
+
} else {
|
|
753
|
+
writeErrorResponse(-32603, toPublicErrorMessage(err), request.id ?? null);
|
|
557
754
|
}
|
|
558
755
|
}
|
|
559
|
-
}
|
|
756
|
+
};
|
|
560
757
|
|
|
561
|
-
process.stdin.on('
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
758
|
+
process.stdin.on('data', chunk => {
|
|
759
|
+
for (const inputEvent of lineBuffer.push(chunk)) {
|
|
760
|
+
const task = processInputEvent(inputEvent).finally(() => inFlight.delete(task));
|
|
761
|
+
inFlight.add(task);
|
|
762
|
+
}
|
|
565
763
|
});
|
|
566
764
|
|
|
765
|
+
// The client closing stdin means it is gone (e.g. a reconnect started a new
|
|
766
|
+
// proxy). Give in-flight requests a short grace period, then exit even if one
|
|
767
|
+
// is stuck, so this process never outlives its client.
|
|
768
|
+
let exiting = false;
|
|
769
|
+
const exitAfterInFlight = () => {
|
|
770
|
+
if (exiting) return;
|
|
771
|
+
exiting = true;
|
|
772
|
+
info('stdin closed, exiting', { inFlight: inFlight.size, graceMs: STDIN_CLOSE_GRACE_MS });
|
|
773
|
+
const grace = new Promise(resolve => setTimeout(resolve, STDIN_CLOSE_GRACE_MS));
|
|
774
|
+
Promise.race([Promise.allSettled([...inFlight]), grace]).then(() => {
|
|
775
|
+
printMetrics();
|
|
776
|
+
process.exit(0);
|
|
777
|
+
});
|
|
778
|
+
};
|
|
779
|
+
process.stdin.on('end', exitAfterInFlight);
|
|
780
|
+
process.stdin.on('close', exitAfterInFlight);
|
|
781
|
+
|
|
567
782
|
process.stdin.resume();
|
|
568
783
|
}
|
|
569
784
|
|
|
@@ -581,11 +796,17 @@ process.on('SIGTERM', () => {
|
|
|
581
796
|
});
|
|
582
797
|
|
|
583
798
|
process.on('uncaughtException', (err) => {
|
|
584
|
-
error('Uncaught exception',
|
|
799
|
+
error('Uncaught exception', {
|
|
800
|
+
errorType: err?.name,
|
|
801
|
+
errorCode: err?.code
|
|
802
|
+
});
|
|
585
803
|
process.exit(1);
|
|
586
804
|
});
|
|
587
805
|
|
|
588
806
|
main().catch((err) => {
|
|
589
|
-
error('Fatal error',
|
|
807
|
+
error('Fatal error', {
|
|
808
|
+
errorType: err?.name,
|
|
809
|
+
errorCode: err?.code
|
|
810
|
+
});
|
|
590
811
|
process.exit(1);
|
|
591
812
|
});
|