@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/index.js CHANGED
@@ -16,10 +16,20 @@
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
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 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,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: 'http://localhost:3000/callback',
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 token = await retryWithBackoff(
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(`${config.baseUrl}/oauth/token`, {
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
- rejectUnauthorized: false,
306
+ // UTEAMUP-SEC-2026-05-02-008: see authorize handler above.
307
+ rejectUnauthorized: !config.insecureTLS,
232
308
  timeout: config.requestTimeout
233
- }, (res) => {
234
- let data = '';
235
- res.on('data', chunk => data += chunk);
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.length
314
+ dataLength: Buffer.byteLength(data)
240
315
  });
241
316
 
242
317
  // Check for error status codes
243
318
  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.'));
319
+ reject(createHttpError('Authentication failed: verify the configured API credentials', res.statusCode));
245
320
  return;
246
321
  }
247
322
  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
- }
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(new Error(`Token exchange failed (HTTP ${res.statusCode}): ${data}`));
327
+ reject(createHttpError(`Token exchange failed (HTTP ${res.statusCode})`, res.statusCode));
257
328
  return;
258
329
  }
259
330
 
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
- });
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.on('error', reject);
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 = token;
290
- tokenExpiry = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7 days
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', err);
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 (e) {
333
- debug('SSE data parse error', { error: e.message });
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: data.length
588
+ bodyLength: requestBytes
360
589
  });
361
590
 
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
- });
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
- req.on('error', reject);
428
- req.write(data);
429
- req.end();
430
- });
431
- },
432
- {
433
- maxRetries: config.maxRetries,
434
- operationName: `MCP request [${request.method}]`
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
- error: err.message
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
- const token = await getAccessToken();
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.message}`);
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
- 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);
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
- if (!line) continue;
686
+ const line = inputEvent.line.trim();
687
+ if (!line) return;
505
688
 
506
- const requestId = generateRequestId();
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
- try {
509
- const request = JSON.parse(line);
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
- info('Processing request', {
512
- requestId,
513
- method: request.method,
514
- id: request.id
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
- 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
- });
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
- // 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
- }
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
- const errorResponse = {
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
- 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');
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('end', () => {
562
- info('stdin closed, exiting');
563
- printMetrics();
564
- process.exit(0);
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', err);
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', err);
809
+ error('Fatal error', {
810
+ errorType: err?.name,
811
+ errorCode: err?.code
812
+ });
590
813
  process.exit(1);
591
814
  });