@mcp-abap-adt/proxy 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +669 -17
  3. package/README.md +79 -8
  4. package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
  5. package/dist/index.d.ts +10 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +54 -97
  8. package/dist/lib/stores.d.ts +23 -2
  9. package/dist/lib/stores.d.ts.map +1 -1
  10. package/dist/lib/stores.js +56 -1
  11. package/dist/mcp/cli.d.ts +2 -0
  12. package/dist/mcp/cli.d.ts.map +1 -0
  13. package/dist/mcp/cli.js +21 -0
  14. package/dist/mcp/configs.d.ts +33 -0
  15. package/dist/mcp/configs.d.ts.map +1 -0
  16. package/dist/mcp/configs.js +142 -0
  17. package/dist/mcp/ports.d.ts +15 -0
  18. package/dist/mcp/ports.d.ts.map +1 -0
  19. package/dist/mcp/ports.js +35 -0
  20. package/dist/mcp/registry.d.ts +57 -0
  21. package/dist/mcp/registry.d.ts.map +1 -0
  22. package/dist/mcp/registry.js +145 -0
  23. package/dist/mcp/server.d.ts +34 -0
  24. package/dist/mcp/server.d.ts.map +1 -0
  25. package/dist/mcp/server.js +79 -0
  26. package/dist/mcp/shutdown.d.ts +37 -0
  27. package/dist/mcp/shutdown.d.ts.map +1 -0
  28. package/dist/mcp/shutdown.js +82 -0
  29. package/dist/mcp/supervisor.d.ts +125 -0
  30. package/dist/mcp/supervisor.d.ts.map +1 -0
  31. package/dist/mcp/supervisor.js +330 -0
  32. package/dist/mcp/tools.d.ts +28 -0
  33. package/dist/mcp/tools.d.ts.map +1 -0
  34. package/dist/mcp/tools.js +152 -0
  35. package/dist/proxy/btpProxy.d.ts +24 -73
  36. package/dist/proxy/btpProxy.d.ts.map +1 -1
  37. package/dist/proxy/btpProxy.js +65 -616
  38. package/dist/proxy/credentials.d.ts +45 -0
  39. package/dist/proxy/credentials.d.ts.map +1 -0
  40. package/dist/proxy/credentials.js +41 -0
  41. package/dist/proxy/requestHandler.d.ts +38 -0
  42. package/dist/proxy/requestHandler.d.ts.map +1 -0
  43. package/dist/proxy/requestHandler.js +73 -0
  44. package/dist/proxy/reverseProxy.d.ts +11 -2
  45. package/dist/proxy/reverseProxy.d.ts.map +1 -1
  46. package/dist/proxy/reverseProxy.js +52 -9
  47. package/dist/router/headerAnalyzer.js +2 -2
  48. package/dist/router/requestInterceptor.js +9 -9
  49. package/docs/API.md +172 -0
  50. package/docs/ARCHITECTURE.md +322 -0
  51. package/docs/CLIENT_SETUP.md +413 -0
  52. package/docs/CONFIGURATION.md +258 -0
  53. package/docs/MIGRATION-4.0.md +125 -0
  54. package/docs/ROUTING_LOGIC.md +126 -0
  55. package/docs/TROUBLESHOOTING.md +488 -0
  56. package/docs/USAGE.md +422 -0
  57. package/docs/YAML_CONFIG.md +273 -0
  58. package/docs/mcp-proxy-config.example.yaml +62 -0
  59. package/package.json +17 -10
  60. package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
  61. package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
  62. package/dist/proxy/cloudLlmHubProxy.js +0 -3
@@ -5,20 +5,16 @@
5
5
  * For requests with x-sap-destination, proxies to target MCP server
6
6
  * with JWT token from auth-broker (XSUAA/BTP)
7
7
  */
8
- var __importDefault = (this && this.__importDefault) || function (mod) {
9
- return (mod && mod.__esModule) ? mod : { "default": mod };
10
- };
11
8
  Object.defineProperty(exports, "__esModule", { value: true });
12
9
  exports.BtpProxy = void 0;
13
10
  exports.shouldWriteStderr = shouldWriteStderr;
14
11
  exports.createBtpProxy = createBtpProxy;
15
12
  const auth_broker_1 = require("@mcp-abap-adt/auth-broker");
16
13
  const auth_providers_1 = require("@mcp-abap-adt/auth-providers");
17
- const interfaces_1 = require("@mcp-abap-adt/interfaces");
18
- const axios_1 = __importDefault(require("axios"));
19
14
  const config_js_1 = require("../lib/config.js");
20
15
  const logger_js_1 = require("../lib/logger.js");
21
16
  const stores_js_1 = require("../lib/stores.js");
17
+ const credentials_js_1 = require("./credentials.js");
22
18
  /**
23
19
  * Default port for the local OAuth2 callback server, kept away from the
24
20
  * proxy's own default HTTP port (3001). auth-providers@2.0.0's own default
@@ -70,14 +66,9 @@ function shouldWriteStderr() {
70
66
  * BTP Proxy Client
71
67
  */
72
68
  class BtpProxy {
73
- axiosInstance;
74
69
  defaultBtpAuthBroker;
75
70
  btpAuthBrokers = new Map();
76
- tokenCache = new Map();
77
- refreshTimers = new Map();
78
- TOKEN_CACHE_TTL = 30 * 60 * 1000; // fallback if JWT has no exp
79
- REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
80
- circuitBreaker;
71
+ credentials;
81
72
  config;
82
73
  unsafe;
83
74
  constructor(defaultBtpAuthBroker, config) {
@@ -100,53 +91,15 @@ class BtpProxy {
100
91
  type: 'CONFIG_FALLBACK',
101
92
  });
102
93
  }
103
- // Initialize circuit breaker
104
- this.circuitBreaker = new errorHandler_js_1.CircuitBreaker(this.config.circuitBreakerThreshold || 5, this.config.circuitBreakerTimeout || 60000);
105
- // Create axios instance without baseURL - we'll use full URLs from BTP destination
106
- const https = require('node:https');
107
- this.axiosInstance = axios_1.default.create({
108
- timeout: this.config.requestTimeout || 60000,
109
- headers: {
110
- [interfaces_1.HEADER_CONTENT_TYPE]: 'application/json',
111
- },
112
- httpsAgent: new https.Agent({
113
- rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
114
- keepAlive: true,
115
- }),
116
- });
117
- // Add request interceptor for logging
118
- this.axiosInstance.interceptors.request.use((config) => {
119
- logger_js_1.logger?.debug('Proxying request to target service', {
120
- type: 'PROXY_REQUEST',
121
- method: config.method,
122
- url: config.url,
123
- baseURL: config.baseURL,
124
- });
125
- return config;
126
- }, (error) => {
127
- logger_js_1.logger?.error('Request interceptor error', {
128
- type: 'MCP_PROXY_REQUEST_ERROR',
129
- error: error instanceof Error ? error.message : String(error),
130
- });
131
- return Promise.reject(error);
132
- });
133
- // Add response interceptor for logging
134
- this.axiosInstance.interceptors.response.use((response) => {
135
- logger_js_1.logger?.debug('Received response from target service', {
136
- type: 'PROXY_RESPONSE',
137
- status: response.status,
138
- url: response.config.url,
139
- });
140
- return response;
141
- }, (error) => {
142
- logger_js_1.logger?.error('Response interceptor error', {
143
- type: 'MCP_PROXY_RESPONSE_ERROR',
144
- error: error instanceof Error ? error.message : String(error),
145
- status: error.response?.status,
146
- url: error.config?.url,
147
- });
148
- return Promise.reject(error);
149
- });
94
+ // One credential per destination, over the brokers this class already
95
+ // caches. The lookup is passed rather than the broker itself so a
96
+ // destination that has never been seen still gets one built.
97
+ this.credentials = new credentials_js_1.DestinationCredentials((destination) => this.getOrCreateBtpAuthBroker(destination));
98
+ // No circuit breaker. It only ever guarded the buffered axios forward that
99
+ // M2 deleted, and the streaming path has nowhere to put one without
100
+ // buffering the response again. `circuitBreakerThreshold` and
101
+ // `circuitBreakerTimeout` are still accepted so existing configs load, and
102
+ // are documented as inert.
150
103
  }
151
104
  /**
152
105
  * Initialize authentication for destination (eager auth)
@@ -159,7 +112,7 @@ class BtpProxy {
159
112
  });
160
113
  try {
161
114
  // Just getting the token will trigger authentication
162
- await this.getJwtToken(destination);
115
+ await this.getAuthorizationHeader(destination);
163
116
  logger_js_1.logger?.info('BTP proxy authentication initialized successfully', {
164
117
  type: 'BTP_PROXY_INIT_SUCCESS',
165
118
  destination,
@@ -340,170 +293,81 @@ class BtpProxy {
340
293
  return broker;
341
294
  }
342
295
  /**
343
- * Get JWT token for BTP destination from auth-broker with retry and token refresh
344
- * @param destination Destination name
345
- * @param forceRefresh Force token refresh
296
+ * The `Authorization` header value for a destination, or `null` when this
297
+ * credential is not a header at all.
298
+ *
299
+ * A complete header value, not a token: the caller puts it on the request as
300
+ * it stands. The credential is asked every time because it renews behind this
301
+ * call — a cache here would serve the stale token and hide the renewal the
302
+ * broker exists to do, which is precisely what the token cache, the JWT `exp`
303
+ * decoder and the per-destination refresh timer that used to live here did.
304
+ *
305
+ * Retried, because a refusal here is not always an answer. In the standalone
306
+ * proxy an authentication failure is FATAL — it exits so something can start
307
+ * it again — so without this a single transient UAA 503 kills a proxy that
308
+ * used to ride it out. Only failures that can get better are retried;
309
+ * `isRetryableError` says which, and a missing service key is not one of
310
+ * them, so it fails on the first attempt instead of three times slower.
346
311
  */
347
- async getJwtToken(destination, forceRefresh = false) {
348
- logger_js_1.logger?.info('Getting JWT token', {
349
- type: 'JWT_TOKEN_GET_START',
312
+ async getAuthorizationHeader(destination) {
313
+ logger_js_1.logger?.debug('Asking the credential for a header', {
314
+ type: 'CREDENTIAL_HEADER_GET',
350
315
  destination,
351
316
  });
352
- const authBroker = await this.getOrCreateBtpAuthBroker(destination);
353
- // Check cache first (unless force refresh)
354
- if (!forceRefresh) {
355
- const cached = this.tokenCache.get(destination);
356
- if (cached && cached.expiresAt > Date.now()) {
357
- logger_js_1.logger?.debug('Using cached JWT token', {
358
- type: 'JWT_TOKEN_CACHE_HIT',
359
- destination,
360
- });
361
- return cached.token;
362
- }
363
- }
364
- // Retry logic for token retrieval
365
317
  const retryOptions = {
366
318
  maxRetries: this.config.maxRetries || 3,
367
319
  retryDelay: this.config.retryDelay || 1000,
368
320
  retryableStatusCodes: [500, 502, 503, 504],
369
321
  };
370
322
  try {
371
- const token = await (0, errorHandler_js_1.retryWithBackoff)(async () => {
372
- // Clear cache if force refresh
373
- if (forceRefresh) {
374
- this.tokenCache.delete(destination);
375
- }
376
- logger_js_1.logger?.debug('Getting JWT token from auth-broker', {
377
- type: 'JWT_TOKEN_REQUEST_START',
378
- destination,
379
- forceRefresh,
380
- });
381
- // Get token from auth-broker
382
- const token = await authBroker.getToken(destination);
383
- this.cacheToken(destination, token);
384
- logger_js_1.logger?.debug('Retrieved JWT token from auth-broker', {
385
- type: 'JWT_TOKEN_RETRIEVED',
386
- destination,
387
- forceRefresh,
388
- });
389
- return token;
323
+ return await (0, errorHandler_js_1.retryWithBackoff)(async () => {
324
+ const { credential } = await this.credentials.get(destination);
325
+ return credential.authorizationHeader();
390
326
  }, retryOptions);
391
- return token;
392
327
  }
393
328
  catch (error) {
394
- let errorMessage = error instanceof Error ? error.message : String(error);
395
- // Rewrite error message to remove .env file references (proxy only uses service keys)
396
- if (errorMessage.includes('.env') || errorMessage.includes('mcp.env')) {
397
- const searchedInMatch = errorMessage.match(/Searched in:\s*([\s\S]*?)(?:\n|$)/);
398
- const searchedPaths = searchedInMatch
399
- ? searchedInMatch[1]
400
- .trim()
401
- .split('\n')
402
- .map((p) => p.trim().replace(/^-\s*/, ''))
403
- .filter((p) => p)
404
- : [];
405
- errorMessage =
406
- `Service key file not found for destination "${destination}".\n` +
407
- `Please create service key file: ${destination}.json\n`;
408
- if (searchedPaths.length > 0) {
409
- errorMessage += `Searched in:\n`;
410
- searchedPaths.forEach((path) => {
411
- errorMessage += ` - ${path}\n`;
412
- });
413
- }
414
- else {
415
- const isWindows = process.platform === 'win32';
416
- const homeDir = require('node:os').homedir();
417
- const defaultPath = isWindows
418
- ? require('node:path').join(homeDir, 'Documents', 'mcp-abap-adt', 'service-keys')
419
- : require('node:path').join(homeDir, '.config', 'mcp-abap-adt', 'service-keys');
420
- errorMessage += `Searched in:\n - ${defaultPath}\n`;
421
- }
422
- }
423
- logger_js_1.logger?.error('Failed to get JWT token from auth-broker after retries', {
424
- type: 'JWT_TOKEN_ERROR',
329
+ const message = this.explainAuthFailure(error, destination);
330
+ logger_js_1.logger?.error('Failed to get an authorization header', {
331
+ type: 'CREDENTIAL_HEADER_ERROR',
425
332
  destination,
426
- error: errorMessage,
333
+ error: message,
427
334
  });
428
335
  if (shouldWriteStderr()) {
429
- process.stderr.write(`[MCP Proxy] ✗ ${errorMessage}`);
336
+ process.stderr.write(`[MCP Proxy] ✗ ${message}\n`);
430
337
  }
431
- throw new Error(errorMessage);
432
- }
433
- }
434
- /**
435
- * Decode JWT exp claim (seconds since epoch). Returns null if not parseable.
436
- */
437
- decodeJwtExp(token) {
438
- const parts = token.split('.');
439
- if (parts.length < 2)
440
- return null;
441
- try {
442
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
443
- return typeof payload.exp === 'number' ? payload.exp : null;
444
- }
445
- catch {
446
- return null;
447
- }
448
- }
449
- /**
450
- * Cache token with expiry derived from JWT, and schedule proactive refresh
451
- * 5 minutes before expiry.
452
- */
453
- cacheToken(destination, token) {
454
- const exp = this.decodeJwtExp(token);
455
- const expiresAt = exp !== null ? exp * 1000 : Date.now() + this.TOKEN_CACHE_TTL;
456
- this.tokenCache.set(destination, { token, expiresAt });
457
- this.scheduleProactiveRefresh(destination, expiresAt);
458
- }
459
- /**
460
- * Schedule background refresh REFRESH_LEAD_MS before token expiry.
461
- */
462
- scheduleProactiveRefresh(destination, expiresAt) {
463
- const existing = this.refreshTimers.get(destination);
464
- if (existing)
465
- clearTimeout(existing);
466
- const delay = expiresAt - Date.now() - this.REFRESH_LEAD_MS;
467
- if (delay <= 0) {
468
- // Token already within lead window or expired; skip scheduling.
469
- this.refreshTimers.delete(destination);
470
- return;
338
+ throw new Error(message);
471
339
  }
472
- const timer = setTimeout(() => {
473
- this.refreshTimers.delete(destination);
474
- this.proactiveRefresh(destination).catch((err) => {
475
- logger_js_1.logger?.warn('Proactive token refresh failed, will retry reactively', {
476
- type: 'JWT_TOKEN_PROACTIVE_REFRESH_ERROR',
477
- destination,
478
- error: err instanceof Error ? err.message : String(err),
479
- });
480
- });
481
- }, delay);
482
- // Don't keep the event loop alive just for refresh timers.
483
- timer.unref?.();
484
- this.refreshTimers.set(destination, timer);
485
340
  }
486
341
  /**
487
- * Background refresh via auth-broker's refreshToken grant.
342
+ * Turn the broker's failure into one that names what this proxy needs.
343
+ *
344
+ * The broker speaks of `.env` and `mcp.env` because it can be fed either way.
345
+ * This proxy only ever reads service keys, so passing that message through
346
+ * told the most common failure — a missing key — to go and create a file that
347
+ * would be ignored.
488
348
  */
489
- async proactiveRefresh(destination) {
490
- logger_js_1.logger?.info('Proactively refreshing JWT token', {
491
- type: 'JWT_TOKEN_PROACTIVE_REFRESH',
492
- destination,
493
- });
494
- const broker = await this.getOrCreateBtpAuthBroker(destination);
495
- const token = await broker.refreshToken(destination);
496
- this.cacheToken(destination, token);
349
+ explainAuthFailure(error, destination) {
350
+ const original = error instanceof Error ? error.message : String(error);
351
+ if (!original.includes('.env') && !original.includes('mcp.env')) {
352
+ return original;
353
+ }
354
+ const searched = original.match(/Searched in:\s*([\s\S]*?)(?:\n\n|$)/);
355
+ const paths = searched
356
+ ? searched[1]
357
+ .trim()
358
+ .split('\n')
359
+ .map((p) => p.trim().replace(/^-\s*/, ''))
360
+ .filter(Boolean)
361
+ : [(0, stores_js_1.storeDir)('service-keys')];
362
+ return [
363
+ `Service key file not found for destination "${destination}".`,
364
+ `Please create service key file: ${destination}.json`,
365
+ 'Searched in:',
366
+ ...paths.map((p) => ` - ${p}`),
367
+ ].join('\n');
497
368
  }
498
- /**
499
- * Cancel all pending refresh timers. Call on shutdown.
500
- */
501
369
  dispose() {
502
- for (const timer of this.refreshTimers.values()) {
503
- clearTimeout(timer);
504
- }
505
- this.refreshTimers.clear();
506
- this.tokenCache.clear();
370
+ this.credentials.clear();
507
371
  this.btpAuthBrokers.clear();
508
372
  }
509
373
  /**
@@ -522,421 +386,6 @@ class BtpProxy {
522
386
  throw new Error(`No target URL found for destination "${destination}". ` +
523
387
  `Set targetUrl in config or ensure service key contains abap.url.`);
524
388
  }
525
- /**
526
- * Helper function to extract string value from header (handles arrays)
527
- */
528
- getHeaderValue(headerValue) {
529
- if (!headerValue)
530
- return undefined;
531
- if (Array.isArray(headerValue)) {
532
- return headerValue[0]?.trim();
533
- }
534
- return typeof headerValue === 'string' ? headerValue.trim() : undefined;
535
- }
536
- /**
537
- * Build proxy request with JWT token (BTP authentication only)
538
- *
539
- * Process flow:
540
- *
541
- * 1. BTP Authentication (XSUAA):
542
- * 1.1 If x-sap-destination header exists:
543
- * - Check map for broker with key = destination, get or create, save to map
544
- * - Get token from xsuaa broker
545
- * - Add/replace Authorization: Bearer <token> header
546
- * 1.2 If header doesn't exist but --btp parameter exists:
547
- * - Use destination from parameter, get or create broker, save to map
548
- * - Get token from xsuaa broker
549
- * - Add/replace Authorization: Bearer <token> header
550
- * 1.3 If neither header nor parameter:
551
- * - Do nothing, pass request further
552
- */
553
- async buildProxyRequest(originalRequest, routingDecision, originalHeaders, forceTokenRefresh = false) {
554
- logger_js_1.logger?.info('=== BUILD PROXY REQUEST - ORIGINAL ===', {
555
- type: 'BUILD_PROXY_REQUEST_ORIGINAL',
556
- originalRequestId: originalRequest.id,
557
- originalRequestIdType: typeof originalRequest.id,
558
- originalRequestMethod: originalRequest.method,
559
- fullOriginalRequest: JSON.stringify(originalRequest),
560
- });
561
- // Build headers for target MCP server
562
- const proxyHeaders = {
563
- [interfaces_1.HEADER_CONTENT_TYPE]: 'application/json',
564
- [interfaces_1.HEADER_ACCEPT]: 'application/json, application/x-ndjson, text/event-stream',
565
- };
566
- // ============================================
567
- // BTP Authentication (XSUAA)
568
- // ============================================
569
- const btpDestinationHeader = this.getHeaderValue(originalHeaders[interfaces_1.HEADER_SAP_DESTINATION]);
570
- const btpDestinationFromParam = routingDecision.btpDestination && !btpDestinationHeader
571
- ? routingDecision.btpDestination
572
- : undefined;
573
- let btpDestination;
574
- if (btpDestinationHeader) {
575
- btpDestination = btpDestinationHeader;
576
- logger_js_1.logger?.debug('Using x-sap-destination from header', {
577
- type: 'BTP_DESTINATION_FROM_HEADER',
578
- destination: btpDestination,
579
- });
580
- }
581
- else if (btpDestinationFromParam) {
582
- btpDestination = btpDestinationFromParam;
583
- logger_js_1.logger?.debug('Using x-sap-destination from parameter', {
584
- type: 'BTP_DESTINATION_FROM_PARAM',
585
- destination: btpDestination,
586
- });
587
- }
588
- if (btpDestination) {
589
- const _btpBroker = await this.getOrCreateBtpAuthBroker(btpDestination);
590
- const existingAuth = originalHeaders[interfaces_1.HEADER_AUTHORIZATION.toLowerCase()] ||
591
- originalHeaders[interfaces_1.HEADER_AUTHORIZATION];
592
- const hasExistingAuth = !!existingAuth;
593
- const authToken = await this.getJwtToken(btpDestination, forceTokenRefresh);
594
- proxyHeaders[interfaces_1.HEADER_AUTHORIZATION] = `Bearer ${authToken}`;
595
- logger_js_1.logger?.debug(hasExistingAuth
596
- ? 'Replaced existing Authorization header with BTP token'
597
- : 'Added BTP Cloud authorization token', {
598
- type: hasExistingAuth
599
- ? 'BTP_AUTH_TOKEN_REPLACED'
600
- : 'BTP_AUTH_TOKEN_ADDED',
601
- destination: btpDestination,
602
- hadExistingAuth: hasExistingAuth,
603
- });
604
- }
605
- else {
606
- logger_js_1.logger?.debug('No BTP destination - skipping authentication', {
607
- type: 'BTP_AUTH_SKIPPED',
608
- });
609
- }
610
- // Preserve other SAP headers if provided by client
611
- const sapHeaders = [interfaces_1.HEADER_SAP_CLIENT, interfaces_1.HEADER_SAP_DESTINATION_SERVICE];
612
- for (const headerName of sapHeaders) {
613
- const value = originalHeaders[headerName];
614
- if (value) {
615
- proxyHeaders[headerName] = Array.isArray(value) ? value[0] : value;
616
- }
617
- }
618
- // Get MCP server URL from BTP destination service key
619
- let baseUrl;
620
- if (btpDestination) {
621
- try {
622
- const btpBroker = await this.getOrCreateBtpAuthBroker(btpDestination, routingDecision.targetUrl);
623
- const connConfig = await btpBroker.getConnectionConfig(btpDestination);
624
- // Use service key URL as base (if available)
625
- if (connConfig?.serviceUrl) {
626
- baseUrl = connConfig.serviceUrl;
627
- logger_js_1.logger?.debug('Using MCP URL from BTP destination service key', {
628
- type: 'MCP_URL_FROM_BTP_DESTINATION',
629
- destination: btpDestination,
630
- url: baseUrl,
631
- });
632
- }
633
- else {
634
- logger_js_1.logger?.warn('BTP destination service key does not contain service URL', {
635
- type: 'BTP_DESTINATION_NO_URL',
636
- destination: btpDestination,
637
- });
638
- }
639
- }
640
- catch (error) {
641
- logger_js_1.logger?.warn('Failed to get URL from BTP destination service key', {
642
- type: 'BTP_DESTINATION_URL_ERROR',
643
- destination: btpDestination,
644
- error: error instanceof Error ? error.message : String(error),
645
- });
646
- }
647
- }
648
- // Apply Target URL override logic
649
- if (routingDecision.targetUrl) {
650
- if (baseUrl) {
651
- logger_js_1.logger?.debug('Overriding service key URL with explicit target URL', {
652
- type: 'TARGET_URL_OVERRIDE',
653
- url: routingDecision.targetUrl,
654
- serviceKeyUrl: baseUrl,
655
- });
656
- }
657
- else {
658
- logger_js_1.logger?.debug('Using explicit target URL from configuration', {
659
- type: 'TARGET_URL_FROM_CONFIG',
660
- url: routingDecision.targetUrl,
661
- });
662
- }
663
- baseUrl = routingDecision.targetUrl;
664
- }
665
- if (!baseUrl) {
666
- throw new Error('Cannot determine target URL: provide btpDestination with service key containing URL OR use --target-url');
667
- }
668
- // Construct full MCP endpoint URL
669
- let fullUrl;
670
- // Determine the path to append
671
- // If original request has a URL/path, use it. Otherwise default to nothing/root.
672
- const requestPath = originalRequest.url || '';
673
- // Check if we should use URL as-is
674
- // 1. If it already looks like an MCP URL
675
- // 2. OR if it was explicitly provided via targetUrl (user knows best)
676
- const isExplicitTarget = !!routingDecision.targetUrl;
677
- const hasMcpPath = baseUrl.includes('/mcp/') ||
678
- baseUrl.endsWith('/mcp') ||
679
- baseUrl.includes('/mcp/stream/');
680
- if (isExplicitTarget) {
681
- // Explicit Target URL logic (as per User request):
682
- // "whatever endpoint [proxy] received, it adds to the target URL"
683
- // Use join logic to avoid double slashes and ensure correctness
684
- // Remove trailing slash from base
685
- const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
686
- // Ensure path starts with slash if not empty
687
- const path = requestPath.startsWith('/')
688
- ? requestPath
689
- : `/${requestPath}`;
690
- // If requestPath is empty or just '/', ensure we don't end up with empty path if base is root
691
- // But typically we just append.
692
- fullUrl = `${base}${path}`;
693
- logger_js_1.logger?.debug('Using explicit target URL with forwarded path', {
694
- type: 'TARGET_URL_PATH_FORWARDING',
695
- base,
696
- path,
697
- final: fullUrl,
698
- });
699
- }
700
- else if (hasMcpPath) {
701
- // Use as-is (strip trailing slash if present, though typically not needed if strictly as-is, but good practice for consistency)
702
- fullUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
703
- logger_js_1.logger?.debug('Using target URL as-is', {
704
- type: 'MCP_URL_AS_IS',
705
- original: baseUrl,
706
- final: fullUrl,
707
- reason: 'path detection',
708
- });
709
- }
710
- else {
711
- // Default: append MCP path
712
- const mcpPath = '/mcp/stream/http';
713
- fullUrl = baseUrl.endsWith('/')
714
- ? `${baseUrl.slice(0, -1)}${mcpPath}`
715
- : `${baseUrl}${mcpPath}`;
716
- logger_js_1.logger?.debug('Appended MCP path to base URL', {
717
- type: 'MCP_URL_APPENDED',
718
- original: baseUrl,
719
- final: fullUrl,
720
- });
721
- }
722
- const builtHeaders = {};
723
- for (const [key, value] of Object.entries(proxyHeaders)) {
724
- const lowerKey = key.toLowerCase();
725
- if (lowerKey.includes('token') ||
726
- lowerKey.includes('authorization') ||
727
- lowerKey.includes('password') ||
728
- lowerKey.includes('secret')) {
729
- builtHeaders[key] = value
730
- ? `${String(value).substring(0, 20)}...`
731
- : '[REDACTED]';
732
- }
733
- else {
734
- builtHeaders[key] = String(value || '');
735
- }
736
- }
737
- const sanitizedRequestParams = {};
738
- const requestData = originalRequest.data;
739
- if (requestData &&
740
- requestData.params &&
741
- typeof requestData.params === 'object' &&
742
- requestData.params !== null) {
743
- const params = requestData.params;
744
- // ... (existing sanitization logic) ...
745
- if (params.arguments &&
746
- typeof params.arguments === 'object' &&
747
- params.arguments !== null) {
748
- sanitizedRequestParams.arguments = {};
749
- const sanitizedArgs = sanitizedRequestParams.arguments;
750
- for (const [key, value] of Object.entries(params.arguments)) {
751
- const lowerKey = key.toLowerCase();
752
- if (lowerKey.includes('password') ||
753
- lowerKey.includes('token') ||
754
- lowerKey.includes('secret')) {
755
- sanitizedArgs[key] = '[REDACTED]';
756
- }
757
- else {
758
- sanitizedArgs[key] = value;
759
- }
760
- }
761
- }
762
- for (const [key, value] of Object.entries(params)) {
763
- if (key === 'arguments')
764
- continue;
765
- sanitizedRequestParams[key] = value;
766
- }
767
- }
768
- logger_js_1.logger?.info('=== BUILDING PROXY REQUEST ===', {
769
- type: 'PROXY_REQUEST_BUILT',
770
- btpDestination: btpDestination,
771
- url: fullUrl,
772
- headers: builtHeaders,
773
- baseUrl,
774
- fullUrl,
775
- hasAuthToken: !!proxyHeaders[interfaces_1.HEADER_AUTHORIZATION],
776
- btpSource: btpDestinationHeader
777
- ? 'header'
778
- : btpDestinationFromParam
779
- ? 'parameter'
780
- : 'none',
781
- requestMethod: originalRequest.method,
782
- requestId: originalRequest.id,
783
- requestParams: sanitizedRequestParams,
784
- });
785
- // Return axios config with full URL
786
- // Use original request method
787
- const axiosConfig = {
788
- method: originalRequest.method,
789
- url: fullUrl,
790
- headers: proxyHeaders,
791
- data: originalRequest.data, // Send the original data (body)
792
- };
793
- logger_js_1.logger?.debug('Axios request config', {
794
- type: 'AXIOS_REQUEST_CONFIG',
795
- method: axiosConfig.method,
796
- url: axiosConfig.url,
797
- headers: Object.keys(axiosConfig.headers),
798
- dataKeys: originalRequest ? Object.keys(originalRequest) : [],
799
- });
800
- return axiosConfig;
801
- }
802
- /**
803
- * Proxy MCP request to target server with retry, circuit breaker, and error handling
804
- */
805
- async proxyRequest(originalRequest, routingDecision, originalHeaders) {
806
- // Check circuit breaker
807
- if (!this.circuitBreaker.canProceed()) {
808
- logger_js_1.logger?.warn('Circuit breaker is open, rejecting request', {
809
- type: 'CIRCUIT_BREAKER_REJECTED',
810
- btpDestination: routingDecision.btpDestination,
811
- });
812
- return (0, errorHandler_js_1.createErrorResponse)(originalRequest.id || null, -32001, 'Service temporarily unavailable (circuit breaker open)', { circuitBreakerState: this.circuitBreaker.getState() });
813
- }
814
- const retryOptions = {
815
- maxRetries: this.config.maxRetries || 3,
816
- retryDelay: this.config.retryDelay || 1000,
817
- retryableStatusCodes: [500, 502, 503, 504],
818
- };
819
- try {
820
- let forceTokenRefresh = false;
821
- const executeRequest = async () => {
822
- const proxyConfig = await this.buildProxyRequest(originalRequest, routingDecision, originalHeaders, forceTokenRefresh);
823
- // Log outgoing request details
824
- const outgoingHeaders = {};
825
- for (const [key, value] of Object.entries(proxyConfig.headers || {})) {
826
- const lowerKey = key.toLowerCase();
827
- if (lowerKey.includes('token') ||
828
- lowerKey.includes('authorization') ||
829
- lowerKey.includes('password') ||
830
- lowerKey.includes('secret')) {
831
- outgoingHeaders[key] = value
832
- ? `${String(value).substring(0, 20)}...`
833
- : '[REDACTED]';
834
- }
835
- else {
836
- outgoingHeaders[key] = String(value || '');
837
- }
838
- }
839
- const sanitizedOutgoingBody = {};
840
- if (proxyConfig.data && typeof proxyConfig.data === 'object') {
841
- if (proxyConfig.data.params &&
842
- typeof proxyConfig.data.params === 'object' &&
843
- proxyConfig.data.params !== null) {
844
- const params = proxyConfig.data.params;
845
- sanitizedOutgoingBody.params = {};
846
- const sanitizedParams = sanitizedOutgoingBody.params;
847
- if (params.arguments &&
848
- typeof params.arguments === 'object' &&
849
- params.arguments !== null) {
850
- sanitizedParams.arguments = {};
851
- const sanitizedArgs = sanitizedParams.arguments;
852
- for (const [key, value] of Object.entries(params.arguments)) {
853
- const lowerKey = key.toLowerCase();
854
- if (lowerKey.includes('password') ||
855
- lowerKey.includes('token') ||
856
- lowerKey.includes('secret')) {
857
- sanitizedArgs[key] = '[REDACTED]';
858
- }
859
- else {
860
- sanitizedArgs[key] = value;
861
- }
862
- }
863
- }
864
- for (const [key, value] of Object.entries(params)) {
865
- if (key === 'arguments')
866
- continue;
867
- sanitizedParams[key] = value;
868
- }
869
- }
870
- sanitizedOutgoingBody.method = proxyConfig.data.method;
871
- sanitizedOutgoingBody.id = proxyConfig.data.id;
872
- sanitizedOutgoingBody.jsonrpc = proxyConfig.data.jsonrpc;
873
- }
874
- logger_js_1.logger?.info('=== SENDING PROXY REQUEST ===', {
875
- type: 'PROXY_REQUEST_SENDING',
876
- url: proxyConfig.url,
877
- headers: outgoingHeaders,
878
- body: sanitizedOutgoingBody,
879
- });
880
- const response = await this.axiosInstance.request(proxyConfig);
881
- this.circuitBreaker.recordSuccess();
882
- return response;
883
- };
884
- const response = await (0, errorHandler_js_1.retryWithBackoff)(async () => {
885
- try {
886
- return await executeRequest();
887
- }
888
- catch (error) {
889
- if (!forceTokenRefresh && (0, errorHandler_js_1.isTokenExpirationError)(error)) {
890
- const destination = routingDecision.btpDestination ||
891
- this.getHeaderValue(originalHeaders[interfaces_1.HEADER_SAP_DESTINATION]);
892
- logger_js_1.logger?.warn('Token expired, retrying with refreshed token', {
893
- type: 'TOKEN_EXPIRED_RETRY',
894
- destination,
895
- });
896
- if (destination) {
897
- this.tokenCache.delete(destination);
898
- }
899
- forceTokenRefresh = true;
900
- return await executeRequest();
901
- }
902
- throw error;
903
- }
904
- }, retryOptions);
905
- // Return response in JSON-RPC format
906
- return {
907
- jsonrpc: '2.0',
908
- id: originalRequest.id || null,
909
- result: response.data.result,
910
- error: response.data.error,
911
- };
912
- }
913
- catch (error) {
914
- this.circuitBreaker.recordFailure();
915
- let statusCode = 500;
916
- let errorMessage = 'Internal Server Error';
917
- let errorData;
918
- if (axios_1.default.isAxiosError(error)) {
919
- statusCode = error.response?.status || 500;
920
- errorMessage = error.message;
921
- errorData = error.response?.data;
922
- logger_js_1.logger?.error('Axios error in proxy request', {
923
- type: 'AXIOS_PROXY_ERROR',
924
- status: statusCode,
925
- message: errorMessage,
926
- data: errorData,
927
- url: error.config?.url,
928
- });
929
- }
930
- else {
931
- errorMessage = error instanceof Error ? error.message : String(error);
932
- logger_js_1.logger?.error('Generic error in proxy request', {
933
- type: 'GENERIC_PROXY_ERROR',
934
- message: errorMessage,
935
- });
936
- }
937
- return (0, errorHandler_js_1.createErrorResponse)(originalRequest.id || null, -32000, `Proxy error: ${errorMessage}`, errorData);
938
- }
939
- }
940
389
  /**
941
390
  * Create a new BtpProxy instance
942
391
  */