@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/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.8
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, validateConfig, printConfigSummary } = require('./lib/config');
20
- const { trace, debug, info, warn, error } = require('./lib/logger');
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.8';
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 7 days
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: 'http://localhost:3000/callback',
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
- https.get(`${config.baseUrl}/oauth/authorize?${authParams}`, {
175
- rejectUnauthorized: false, // Allow self-signed certs for dev
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
- debug('Authorization redirect received', { location });
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 (location.includes('error=')) {
183
- const url = new URL(location, 'http://localhost');
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 match = location.match(/code=([^&]+)/);
191
- if (match) {
263
+ const authorizationCode = redirectUrl.searchParams.get('code');
264
+ if (authorizationCode) {
192
265
  debug('Authorization code received');
193
- resolve(match[1]);
266
+ resolve(authorizationCode);
194
267
  } else {
195
- reject(new Error('No authorization code in redirect. Location: ' + location));
268
+ reject(new ProxyTransportError('OAuth authorization did not return a code'));
196
269
  }
197
- }).on('error', reject);
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: 'http://localhost:3000/callback',
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 token = await retryWithBackoff(
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(`${config.baseUrl}/oauth/token`, {
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
- rejectUnauthorized: false,
304
+ // UTEAMUP-SEC-2026-05-02-008: see authorize handler above.
305
+ rejectUnauthorized: !config.insecureTLS,
232
306
  timeout: config.requestTimeout
233
- }, (res) => {
234
- let data = '';
235
- res.on('data', chunk => data += chunk);
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.length
312
+ dataLength: Buffer.byteLength(data)
240
313
  });
241
314
 
242
315
  // Check for error status codes
243
316
  if (res.statusCode === 401) {
244
- reject(new Error('Authentication failed: Invalid API Key or Secret. Please verify your credentials in UteamUP Tenant Settings → API Keys.'));
317
+ reject(createHttpError('Authentication failed: verify the configured API credentials', res.statusCode));
245
318
  return;
246
319
  }
247
320
  if (res.statusCode === 403) {
248
- if (data.includes('MCP') || data.includes('mcp')) {
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(new Error(`Token exchange failed (HTTP ${res.statusCode}): ${data}`));
325
+ reject(createHttpError(`Token exchange failed (HTTP ${res.statusCode})`, res.statusCode));
257
326
  return;
258
327
  }
259
328
 
260
- try {
261
- const json = JSON.parse(data);
262
- // Accept both camelCase (accessToken) and snake_case (access_token) formats
263
- const token = json.accessToken || json.access_token;
264
- if (token) {
265
- debug('Access token received successfully');
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.on('error', reject);
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 = token;
290
- tokenExpiry = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7 days
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', err);
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 (e) {
333
- debug('SSE data parse error', { error: e.message });
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: data.length
586
+ bodyLength: requestBytes
360
587
  });
361
588
 
362
- const response = await retryWithBackoff(
363
- async () => {
364
- return new Promise((resolve, reject) => {
365
- const req = https.request(`${config.baseUrl}/mcp`, {
366
- method: 'POST',
367
- headers: {
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
- req.on('error', reject);
428
- req.write(data);
429
- req.end();
430
- });
431
- },
432
- {
433
- maxRetries: config.maxRetries,
434
- operationName: `MCP request [${request.method}]`
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
- error: err.message
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
- const token = await getAccessToken();
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.message}`);
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
- let buffer = '';
495
-
496
- process.stdin.on('data', async (chunk) => {
497
- buffer += chunk.toString();
498
-
499
- let newlineIndex;
500
- while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
501
- const line = buffer.slice(0, newlineIndex).trim();
502
- buffer = buffer.slice(newlineIndex + 1);
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
- if (!line) continue;
684
+ const line = inputEvent.line.trim();
685
+ if (!line) return;
505
686
 
506
- const requestId = generateRequestId();
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
- try {
509
- const request = JSON.parse(line);
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
- info('Processing request', {
512
- requestId,
513
- method: request.method,
514
- id: request.id
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
- const response = await forwardRequest(request, requestId);
518
-
519
- // For notifications (null response), don't write anything to stdout
520
- if (response === null) {
521
- debug('Notification handled, no response sent', { requestId });
522
- continue;
523
- }
524
-
525
- // Ensure response includes request ID for proper JSON-RPC 2.0 format
526
- if (request.id !== undefined && response.id === undefined) {
527
- response.id = request.id;
528
- }
529
-
530
- process.stdout.write(JSON.stringify(response) + '\n');
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
- // Try to extract request ID if possible
539
- let requestIdFromLine = null;
540
- try {
541
- const req = JSON.parse(line);
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
- const errorResponse = {
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
- code: -32603,
551
- message: err.message,
552
- data: config.logLevel === 'DEBUG' || config.logLevel === 'TRACE' ? err.stack : undefined
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('end', () => {
562
- info('stdin closed, exiting');
563
- printMetrics();
564
- process.exit(0);
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', err);
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', err);
807
+ error('Fatal error', {
808
+ errorType: err?.name,
809
+ errorCode: err?.code
810
+ });
590
811
  process.exit(1);
591
812
  });