@mcp-abap-adt/proxy 0.1.5 → 0.1.8

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 (39) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +73 -65
  3. package/bin/mcp-abap-adt-proxy.js +15 -42
  4. package/dist/index.d.ts +3 -12
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +414 -277
  7. package/dist/lib/config.d.ts +13 -4
  8. package/dist/lib/config.d.ts.map +1 -1
  9. package/dist/lib/config.js +110 -93
  10. package/dist/lib/errorHandler.d.ts +3 -3
  11. package/dist/lib/errorHandler.d.ts.map +1 -1
  12. package/dist/lib/errorHandler.js +31 -28
  13. package/dist/lib/logger.d.ts.map +1 -1
  14. package/dist/lib/logger.js +12 -9
  15. package/dist/lib/stores.d.ts +7 -7
  16. package/dist/lib/stores.d.ts.map +1 -1
  17. package/dist/lib/stores.js +46 -80
  18. package/dist/lib/transportConfig.d.ts +1 -1
  19. package/dist/lib/transportConfig.d.ts.map +1 -1
  20. package/dist/lib/transportConfig.js +58 -38
  21. package/dist/proxy/btpProxy.d.ts +102 -0
  22. package/dist/proxy/btpProxy.d.ts.map +1 -0
  23. package/dist/proxy/btpProxy.js +833 -0
  24. package/dist/proxy/cloudLlmHubProxy.d.ts +1 -65
  25. package/dist/proxy/cloudLlmHubProxy.d.ts.map +1 -1
  26. package/dist/proxy/cloudLlmHubProxy.js +1 -441
  27. package/dist/router/headerAnalyzer.d.ts +9 -18
  28. package/dist/router/headerAnalyzer.d.ts.map +1 -1
  29. package/dist/router/headerAnalyzer.js +35 -63
  30. package/dist/router/requestInterceptor.d.ts +5 -11
  31. package/dist/router/requestInterceptor.d.ts.map +1 -1
  32. package/dist/router/requestInterceptor.js +33 -30
  33. package/package.json +33 -15
  34. package/dist/router/directCloudRouter.d.ts +0 -32
  35. package/dist/router/directCloudRouter.d.ts.map +0 -1
  36. package/dist/router/directCloudRouter.js +0 -146
  37. package/dist/router/localBasicRouter.d.ts +0 -23
  38. package/dist/router/localBasicRouter.d.ts.map +0 -1
  39. package/dist/router/localBasicRouter.js +0 -118
@@ -0,0 +1,833 @@
1
+ "use strict";
2
+ /**
3
+ * BTP Proxy - Proxies requests to MCP servers with JWT authentication
4
+ *
5
+ * For requests with x-btp-destination, proxies to target MCP server
6
+ * with JWT token from auth-broker (XSUAA/BTP)
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.BtpProxy = void 0;
13
+ exports.shouldWriteStderr = shouldWriteStderr;
14
+ exports.createBtpProxy = createBtpProxy;
15
+ const auth_broker_1 = require("@mcp-abap-adt/auth-broker");
16
+ 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
+ const config_js_1 = require("../lib/config.js");
20
+ const logger_js_1 = require("../lib/logger.js");
21
+ const stores_js_1 = require("../lib/stores.js");
22
+ /**
23
+ * Adapter to convert proxy Logger to ILogger interface expected by AuthBroker
24
+ */
25
+ const loggerAdapter = {
26
+ debug: (message, meta) => logger_js_1.logger?.debug(message, meta && typeof meta === 'object' && meta !== null
27
+ ? meta
28
+ : undefined),
29
+ info: (message, meta) => logger_js_1.logger?.info(message, meta && typeof meta === 'object' && meta !== null
30
+ ? meta
31
+ : undefined),
32
+ warn: (message, meta) => logger_js_1.logger?.warn(message, meta && typeof meta === 'object' && meta !== null
33
+ ? meta
34
+ : undefined),
35
+ error: (message, meta) => logger_js_1.logger?.error(message, meta && typeof meta === 'object' && meta !== null
36
+ ? meta
37
+ : undefined),
38
+ };
39
+ const errorHandler_js_1 = require("../lib/errorHandler.js");
40
+ /**
41
+ * Check if error messages should be written to stderr
42
+ * Only output in verbose mode and not in test environment
43
+ */
44
+ function shouldWriteStderr() {
45
+ const verboseMode = process.env.MCP_PROXY_VERBOSE === 'true' ||
46
+ process.env.DEBUG === 'true' ||
47
+ process.env.DEBUG?.includes('mcp-proxy') === true;
48
+ const isTestEnv = process.env.NODE_ENV === 'test' ||
49
+ process.env.JEST_WORKER_ID !== undefined ||
50
+ typeof globalThis.jest !== 'undefined';
51
+ return verboseMode && !isTestEnv;
52
+ }
53
+ /**
54
+ * BTP Proxy Client
55
+ */
56
+ class BtpProxy {
57
+ axiosInstance;
58
+ defaultBtpAuthBroker;
59
+ btpAuthBrokers = new Map();
60
+ tokenCache = new Map();
61
+ TOKEN_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
62
+ circuitBreaker;
63
+ config;
64
+ unsafe;
65
+ constructor(defaultBtpAuthBroker, config) {
66
+ this.defaultBtpAuthBroker = defaultBtpAuthBroker;
67
+ this.unsafe = config?.unsafe ?? false;
68
+ // Use provided config if available (from YAML or CLI/ENV, already loaded in index.ts)
69
+ // If no config provided, load from CLI/ENV as fallback
70
+ if (config) {
71
+ this.config = config;
72
+ logger_js_1.logger?.debug('Using provided config in BtpProxy constructor', {
73
+ type: 'CONFIG_PROVIDED',
74
+ btpDestination: this.config.btpDestination,
75
+ configKeys: Object.keys(this.config),
76
+ });
77
+ }
78
+ else {
79
+ // Fallback: load from CLI/ENV (should not happen if called from index.ts)
80
+ this.config = (0, config_js_1.loadConfig)();
81
+ logger_js_1.logger?.info('Using fallback config from CLI/ENV in BtpProxy constructor', {
82
+ type: 'CONFIG_FALLBACK',
83
+ });
84
+ }
85
+ // Initialize circuit breaker
86
+ this.circuitBreaker = new errorHandler_js_1.CircuitBreaker(this.config.circuitBreakerThreshold || 5, this.config.circuitBreakerTimeout || 60000);
87
+ // Create axios instance without baseURL - we'll use full URLs from BTP destination
88
+ const https = require('node:https');
89
+ this.axiosInstance = axios_1.default.create({
90
+ timeout: this.config.requestTimeout || 60000,
91
+ headers: {
92
+ [interfaces_1.HEADER_CONTENT_TYPE]: 'application/json',
93
+ },
94
+ httpsAgent: new https.Agent({
95
+ rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
96
+ keepAlive: true,
97
+ }),
98
+ });
99
+ // Add request interceptor for logging
100
+ this.axiosInstance.interceptors.request.use((config) => {
101
+ logger_js_1.logger?.debug('Proxying request to target service', {
102
+ type: 'PROXY_REQUEST',
103
+ method: config.method,
104
+ url: config.url,
105
+ baseURL: config.baseURL,
106
+ });
107
+ return config;
108
+ }, (error) => {
109
+ logger_js_1.logger?.error('Request interceptor error', {
110
+ type: 'MCP_PROXY_REQUEST_ERROR',
111
+ error: error instanceof Error ? error.message : String(error),
112
+ });
113
+ return Promise.reject(error);
114
+ });
115
+ // Add response interceptor for logging
116
+ this.axiosInstance.interceptors.response.use((response) => {
117
+ logger_js_1.logger?.debug('Received response from target service', {
118
+ type: 'PROXY_RESPONSE',
119
+ status: response.status,
120
+ url: response.config.url,
121
+ });
122
+ return response;
123
+ }, (error) => {
124
+ logger_js_1.logger?.error('Response interceptor error', {
125
+ type: 'MCP_PROXY_RESPONSE_ERROR',
126
+ error: error instanceof Error ? error.message : String(error),
127
+ status: error.response?.status,
128
+ url: error.config?.url,
129
+ });
130
+ return Promise.reject(error);
131
+ });
132
+ }
133
+ /**
134
+ * Initialize authentication for destination (eager auth)
135
+ * This triggers the browser authentication flow immediately if needed.
136
+ */
137
+ async initialize(destination) {
138
+ logger_js_1.logger?.info('Initializing BTP proxy authentication', {
139
+ type: 'BTP_PROXY_INIT',
140
+ destination,
141
+ });
142
+ try {
143
+ // Just getting the token will trigger authentication
144
+ await this.getJwtToken(destination);
145
+ logger_js_1.logger?.info('BTP proxy authentication initialized successfully', {
146
+ type: 'BTP_PROXY_INIT_SUCCESS',
147
+ destination,
148
+ });
149
+ }
150
+ catch (error) {
151
+ logger_js_1.logger?.error('Failed to initialize BTP proxy authentication', {
152
+ type: 'BTP_PROXY_INIT_ERROR',
153
+ destination,
154
+ error: error instanceof Error ? error.message : String(error),
155
+ });
156
+ // We don't throw here to avoid crashing the server on startup,
157
+ // but the user will see the error in logs/stderr (via getJwtToken)
158
+ }
159
+ }
160
+ /**
161
+ * Get or create BTP auth broker for specific destination
162
+ * If destination is not provided, returns default broker
163
+ * If broker doesn't exist in map, creates new one and stores it
164
+ */
165
+ async getOrCreateBtpAuthBroker(destination, targetUrl) {
166
+ // If no destination, use default broker
167
+ if (!destination) {
168
+ return this.defaultBtpAuthBroker;
169
+ }
170
+ // Check if broker exists in map
171
+ let broker = this.btpAuthBrokers.get(destination);
172
+ if (broker) {
173
+ return broker;
174
+ }
175
+ // Create new broker for this destination
176
+ logger_js_1.logger?.info('Creating new BTP auth broker for destination', {
177
+ type: 'BTP_BROKER_CREATE',
178
+ destination,
179
+ });
180
+ const { serviceKeyStore, sessionStore } = await (0, stores_js_1.getPlatformStores)(this.unsafe);
181
+ // We must manually load the credentials because AuthorizationCodeProvider validates them in constructor
182
+ let authConfig = null;
183
+ try {
184
+ // Try service key store first
185
+ if (serviceKeyStore) {
186
+ authConfig = await serviceKeyStore.getAuthorizationConfig(destination);
187
+ }
188
+ // If not found, try session store (though less likely for initial setup)
189
+ if (!authConfig) {
190
+ authConfig = await sessionStore.getAuthorizationConfig(destination);
191
+ }
192
+ }
193
+ catch (error) {
194
+ logger_js_1.logger?.warn('Failed to load auth config for provider initialization', {
195
+ error: error instanceof Error ? error.message : String(error),
196
+ destination,
197
+ });
198
+ }
199
+ if (!authConfig) {
200
+ logger_js_1.logger?.error('Service key not found for destination', {
201
+ destination,
202
+ hint: 'Ensure service key file exists in ~/.config/mcp-abap-adt/service-keys/',
203
+ });
204
+ // We cannot proceed without config, but we'll let it fail with a clear message
205
+ // Or we could throw here.
206
+ // If we don't provide config, provider will throw "Missing required fields".
207
+ }
208
+ // Always use AuthorizationCodeProvider (enforced)
209
+ // Map IAuthorizationConfig (uaaClientId) to ProviderConfig (clientId)
210
+ // Default redirectPort to 3333 to avoid conflict with main proxy port (default 3001)
211
+ const providerConfig = {
212
+ browser: this.config.browser,
213
+ redirectPort: this.config.browserAuthPort || 3333,
214
+ ...(authConfig
215
+ ? {
216
+ uaaUrl: authConfig.uaaUrl,
217
+ clientId: authConfig.uaaClientId,
218
+ clientSecret: authConfig.uaaClientSecret,
219
+ }
220
+ : {}),
221
+ };
222
+ const tokenProvider = new auth_providers_1.AuthorizationCodeProvider(providerConfig);
223
+ broker = new auth_broker_1.AuthBroker({
224
+ serviceKeyStore,
225
+ sessionStore,
226
+ tokenProvider,
227
+ }, this.config.browser, // Pass configured browser (default: 'system')
228
+ logger_js_1.logger);
229
+ this.btpAuthBrokers.set(destination, broker);
230
+ return this.ensureSessionServiceUrl(broker, destination, targetUrl);
231
+ }
232
+ /**
233
+ * Helper to ensure valid serviceUrl in session if override provided
234
+ */
235
+ async ensureSessionServiceUrl(broker, destination, targetUrl) {
236
+ const activeTargetUrl = targetUrl || this.config.targetUrl;
237
+ if (!activeTargetUrl) {
238
+ return broker;
239
+ }
240
+ try {
241
+ // Check if current connection config exists
242
+ const currentConn = await broker.getConnectionConfig(destination);
243
+ // We need to ensure we have a valid session with BOTH serviceUrl AND auth config.
244
+ // Even if serviceUrl matches, the auth config might be missing from the session
245
+ // (which causes ClientCredentialsProvider to fail if initialized with empty config).
246
+ // Cast to any to access potentially private methods if interface restricted
247
+ // biome-ignore lint/suspicious/noExplicitAny: Accessing internal methods for safe injection
248
+ const brokerAny = broker;
249
+ let authConfig = await broker.getAuthorizationConfig(destination);
250
+ if (!authConfig) {
251
+ try {
252
+ if (typeof brokerAny.getAuthorizationConfigFromServiceKey === 'function') {
253
+ authConfig =
254
+ await brokerAny.getAuthorizationConfigFromServiceKey(destination);
255
+ }
256
+ }
257
+ catch (e) {
258
+ logger_js_1.logger?.debug('Could not find auth config for session update', {
259
+ error: String(e),
260
+ });
261
+ }
262
+ }
263
+ if (!authConfig) {
264
+ authConfig = {
265
+ uaaUrl: 'https://placeholder.authentication.sap.hana.ondemand.com',
266
+ uaaClientId: 'placeholder',
267
+ uaaClientSecret: 'placeholder',
268
+ };
269
+ logger_js_1.logger?.info('Using placeholder auth config for session injection', {
270
+ type: 'BTP_SESSION_PLACEHOLDER',
271
+ destination,
272
+ targetUrl: activeTargetUrl,
273
+ });
274
+ }
275
+ if (authConfig) {
276
+ const newConn = {
277
+ ...(currentConn || {}),
278
+ serviceUrl: activeTargetUrl,
279
+ authType: 'jwt',
280
+ // Map XSUAA keys to ClientCredentialsProvider keys
281
+ clientId: authConfig.uaaClientId,
282
+ clientSecret: authConfig.uaaClientSecret,
283
+ uaaUrl: authConfig.uaaUrl,
284
+ };
285
+ if (typeof brokerAny.saveTokenToSession === 'function') {
286
+ await brokerAny.saveTokenToSession(destination, newConn, authConfig);
287
+ logger_js_1.logger?.info('Injected targetUrl and auth config into BTP session', {
288
+ type: 'BTP_SESSION_INJECTION',
289
+ destination,
290
+ url: activeTargetUrl,
291
+ hasClientId: !!newConn.clientId,
292
+ });
293
+ }
294
+ else {
295
+ logger_js_1.logger?.error('saveTokenToSession is not a function on broker', {
296
+ type: 'BTP_SESSION_METHOD_MISSING',
297
+ keys: Object.keys(brokerAny),
298
+ });
299
+ }
300
+ }
301
+ }
302
+ catch (error) {
303
+ logger_js_1.logger?.error('Failed to inject targetUrl into session', {
304
+ type: 'BTP_SESSION_INJECTION_ERROR',
305
+ destination,
306
+ error: error instanceof Error ? error.message : String(error),
307
+ });
308
+ }
309
+ return broker;
310
+ }
311
+ /**
312
+ * Get JWT token for BTP destination from auth-broker with retry and token refresh
313
+ * @param destination Destination name
314
+ * @param forceRefresh Force token refresh
315
+ */
316
+ async getJwtToken(destination, forceRefresh = false) {
317
+ logger_js_1.logger?.info('Getting JWT token', {
318
+ type: 'JWT_TOKEN_GET_START',
319
+ destination,
320
+ });
321
+ const authBroker = await this.getOrCreateBtpAuthBroker(destination);
322
+ // Check cache first (unless force refresh)
323
+ if (!forceRefresh) {
324
+ const cached = this.tokenCache.get(destination);
325
+ if (cached && cached.expiresAt > Date.now()) {
326
+ logger_js_1.logger?.debug('Using cached JWT token', {
327
+ type: 'JWT_TOKEN_CACHE_HIT',
328
+ destination,
329
+ });
330
+ return cached.token;
331
+ }
332
+ }
333
+ // Retry logic for token retrieval
334
+ const retryOptions = {
335
+ maxRetries: this.config.maxRetries || 3,
336
+ retryDelay: this.config.retryDelay || 1000,
337
+ retryableStatusCodes: [500, 502, 503, 504],
338
+ };
339
+ try {
340
+ const token = await (0, errorHandler_js_1.retryWithBackoff)(async () => {
341
+ // Clear cache if force refresh
342
+ if (forceRefresh) {
343
+ this.tokenCache.delete(destination);
344
+ }
345
+ logger_js_1.logger?.debug('Getting JWT token from auth-broker', {
346
+ type: 'JWT_TOKEN_REQUEST_START',
347
+ destination,
348
+ forceRefresh,
349
+ });
350
+ // Get token from auth-broker
351
+ const token = await authBroker.getToken(destination);
352
+ // Cache token (assume it's valid for 30 minutes)
353
+ this.tokenCache.set(destination, {
354
+ token,
355
+ expiresAt: Date.now() + this.TOKEN_CACHE_TTL,
356
+ });
357
+ logger_js_1.logger?.debug('Retrieved JWT token from auth-broker', {
358
+ type: 'JWT_TOKEN_RETRIEVED',
359
+ destination,
360
+ forceRefresh,
361
+ });
362
+ return token;
363
+ }, retryOptions);
364
+ return token;
365
+ }
366
+ catch (error) {
367
+ let errorMessage = error instanceof Error ? error.message : String(error);
368
+ // Rewrite error message to remove .env file references (proxy only uses service keys)
369
+ if (errorMessage.includes('.env') || errorMessage.includes('mcp.env')) {
370
+ const searchedInMatch = errorMessage.match(/Searched in:\s*([\s\S]*?)(?:\n|$)/);
371
+ const searchedPaths = searchedInMatch
372
+ ? searchedInMatch[1]
373
+ .trim()
374
+ .split('\n')
375
+ .map((p) => p.trim().replace(/^-\s*/, ''))
376
+ .filter((p) => p)
377
+ : [];
378
+ errorMessage =
379
+ `Service key file not found for destination "${destination}".\n` +
380
+ `Please create service key file: ${destination}.json\n`;
381
+ if (searchedPaths.length > 0) {
382
+ errorMessage += `Searched in:\n`;
383
+ searchedPaths.forEach((path) => {
384
+ errorMessage += ` - ${path}\n`;
385
+ });
386
+ }
387
+ else {
388
+ const isWindows = process.platform === 'win32';
389
+ const homeDir = require('node:os').homedir();
390
+ const defaultPath = isWindows
391
+ ? require('node:path').join(homeDir, 'Documents', 'mcp-abap-adt', 'service-keys')
392
+ : require('node:path').join(homeDir, '.config', 'mcp-abap-adt', 'service-keys');
393
+ errorMessage += `Searched in:\n - ${defaultPath}\n`;
394
+ }
395
+ }
396
+ logger_js_1.logger?.error('Failed to get JWT token from auth-broker after retries', {
397
+ type: 'JWT_TOKEN_ERROR',
398
+ destination,
399
+ error: errorMessage,
400
+ });
401
+ if (shouldWriteStderr()) {
402
+ process.stderr.write(`[MCP Proxy] ✗ ${errorMessage}`);
403
+ }
404
+ throw new Error(errorMessage);
405
+ }
406
+ }
407
+ /**
408
+ * Helper function to extract string value from header (handles arrays)
409
+ */
410
+ getHeaderValue(headerValue) {
411
+ if (!headerValue)
412
+ return undefined;
413
+ if (Array.isArray(headerValue)) {
414
+ return headerValue[0]?.trim();
415
+ }
416
+ return typeof headerValue === 'string' ? headerValue.trim() : undefined;
417
+ }
418
+ /**
419
+ * Build proxy request with JWT token (BTP authentication only)
420
+ *
421
+ * Process flow:
422
+ *
423
+ * 1. BTP Authentication (XSUAA):
424
+ * 1.1 If x-btp-destination header exists:
425
+ * - Check map for broker with key = destination, get or create, save to map
426
+ * - Get token from xsuaa broker
427
+ * - Add/replace Authorization: Bearer <token> header
428
+ * 1.2 If header doesn't exist but --btp parameter exists:
429
+ * - Use destination from parameter, get or create broker, save to map
430
+ * - Get token from xsuaa broker
431
+ * - Add/replace Authorization: Bearer <token> header
432
+ * 1.3 If neither header nor parameter:
433
+ * - Do nothing, pass request further
434
+ */
435
+ async buildProxyRequest(originalRequest, routingDecision, originalHeaders, forceTokenRefresh = false) {
436
+ logger_js_1.logger?.info('=== BUILD PROXY REQUEST - ORIGINAL ===', {
437
+ type: 'BUILD_PROXY_REQUEST_ORIGINAL',
438
+ originalRequestId: originalRequest.id,
439
+ originalRequestIdType: typeof originalRequest.id,
440
+ originalRequestMethod: originalRequest.method,
441
+ fullOriginalRequest: JSON.stringify(originalRequest),
442
+ });
443
+ // Build headers for target MCP server
444
+ const proxyHeaders = {
445
+ [interfaces_1.HEADER_CONTENT_TYPE]: 'application/json',
446
+ [interfaces_1.HEADER_ACCEPT]: 'application/json, application/x-ndjson, text/event-stream',
447
+ };
448
+ // ============================================
449
+ // BTP Authentication (XSUAA)
450
+ // ============================================
451
+ const btpDestinationHeader = this.getHeaderValue(originalHeaders[interfaces_1.HEADER_BTP_DESTINATION]);
452
+ const btpDestinationFromParam = routingDecision.btpDestination && !btpDestinationHeader
453
+ ? routingDecision.btpDestination
454
+ : undefined;
455
+ let btpDestination;
456
+ if (btpDestinationHeader) {
457
+ btpDestination = btpDestinationHeader;
458
+ logger_js_1.logger?.debug('Using x-btp-destination from header', {
459
+ type: 'BTP_DESTINATION_FROM_HEADER',
460
+ destination: btpDestination,
461
+ });
462
+ }
463
+ else if (btpDestinationFromParam) {
464
+ btpDestination = btpDestinationFromParam;
465
+ logger_js_1.logger?.debug('Using x-btp-destination from parameter', {
466
+ type: 'BTP_DESTINATION_FROM_PARAM',
467
+ destination: btpDestination,
468
+ });
469
+ }
470
+ if (btpDestination) {
471
+ const _btpBroker = await this.getOrCreateBtpAuthBroker(btpDestination);
472
+ const existingAuth = originalHeaders[interfaces_1.HEADER_AUTHORIZATION.toLowerCase()] ||
473
+ originalHeaders[interfaces_1.HEADER_AUTHORIZATION];
474
+ const hasExistingAuth = !!existingAuth;
475
+ const authToken = await this.getJwtToken(btpDestination, forceTokenRefresh);
476
+ proxyHeaders[interfaces_1.HEADER_AUTHORIZATION] = `Bearer ${authToken}`;
477
+ logger_js_1.logger?.debug(hasExistingAuth
478
+ ? 'Replaced existing Authorization header with BTP token'
479
+ : 'Added BTP Cloud authorization token', {
480
+ type: hasExistingAuth
481
+ ? 'BTP_AUTH_TOKEN_REPLACED'
482
+ : 'BTP_AUTH_TOKEN_ADDED',
483
+ destination: btpDestination,
484
+ hadExistingAuth: hasExistingAuth,
485
+ });
486
+ }
487
+ else {
488
+ logger_js_1.logger?.debug('No BTP destination - skipping authentication', {
489
+ type: 'BTP_AUTH_SKIPPED',
490
+ });
491
+ }
492
+ // Preserve other SAP headers if provided by client
493
+ const sapHeaders = [interfaces_1.HEADER_SAP_CLIENT, interfaces_1.HEADER_SAP_DESTINATION_SERVICE];
494
+ for (const headerName of sapHeaders) {
495
+ const value = originalHeaders[headerName];
496
+ if (value) {
497
+ proxyHeaders[headerName] = Array.isArray(value) ? value[0] : value;
498
+ }
499
+ }
500
+ // Get MCP server URL from BTP destination service key
501
+ let baseUrl;
502
+ if (btpDestination) {
503
+ try {
504
+ const btpBroker = await this.getOrCreateBtpAuthBroker(btpDestination, routingDecision.targetUrl);
505
+ const connConfig = await btpBroker.getConnectionConfig(btpDestination);
506
+ // Use service key URL as base (if available)
507
+ if (connConfig?.serviceUrl) {
508
+ baseUrl = connConfig.serviceUrl;
509
+ logger_js_1.logger?.debug('Using MCP URL from BTP destination service key', {
510
+ type: 'MCP_URL_FROM_BTP_DESTINATION',
511
+ destination: btpDestination,
512
+ url: baseUrl,
513
+ });
514
+ }
515
+ else {
516
+ logger_js_1.logger?.warn('BTP destination service key does not contain service URL', {
517
+ type: 'BTP_DESTINATION_NO_URL',
518
+ destination: btpDestination,
519
+ });
520
+ }
521
+ }
522
+ catch (error) {
523
+ logger_js_1.logger?.warn('Failed to get URL from BTP destination service key', {
524
+ type: 'BTP_DESTINATION_URL_ERROR',
525
+ destination: btpDestination,
526
+ error: error instanceof Error ? error.message : String(error),
527
+ });
528
+ }
529
+ }
530
+ // Apply Target URL override logic
531
+ if (routingDecision.targetUrl) {
532
+ if (baseUrl) {
533
+ logger_js_1.logger?.debug('Overriding service key URL with explicit target URL', {
534
+ type: 'TARGET_URL_OVERRIDE',
535
+ url: routingDecision.targetUrl,
536
+ serviceKeyUrl: baseUrl,
537
+ });
538
+ }
539
+ else {
540
+ logger_js_1.logger?.debug('Using explicit target URL from configuration', {
541
+ type: 'TARGET_URL_FROM_CONFIG',
542
+ url: routingDecision.targetUrl,
543
+ });
544
+ }
545
+ baseUrl = routingDecision.targetUrl;
546
+ }
547
+ if (!baseUrl) {
548
+ throw new Error('Cannot determine target URL: provide btpDestination with service key containing URL OR use --target-url');
549
+ }
550
+ // Construct full MCP endpoint URL
551
+ let fullUrl;
552
+ // Determine the path to append
553
+ // If original request has a URL/path, use it. Otherwise default to nothing/root.
554
+ const requestPath = originalRequest.url || '';
555
+ // Check if we should use URL as-is
556
+ // 1. If it already looks like an MCP URL
557
+ // 2. OR if it was explicitly provided via targetUrl (user knows best)
558
+ const isExplicitTarget = !!routingDecision.targetUrl;
559
+ const hasMcpPath = baseUrl.includes('/mcp/') ||
560
+ baseUrl.endsWith('/mcp') ||
561
+ baseUrl.includes('/mcp/stream/');
562
+ if (isExplicitTarget) {
563
+ // Explicit Target URL logic (as per User request):
564
+ // "whatever endpoint [proxy] received, it adds to the target URL"
565
+ // Use join logic to avoid double slashes and ensure correctness
566
+ // Remove trailing slash from base
567
+ const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
568
+ // Ensure path starts with slash if not empty
569
+ const path = requestPath.startsWith('/')
570
+ ? requestPath
571
+ : `/${requestPath}`;
572
+ // If requestPath is empty or just '/', ensure we don't end up with empty path if base is root
573
+ // But typically we just append.
574
+ fullUrl = `${base}${path}`;
575
+ logger_js_1.logger?.debug('Using explicit target URL with forwarded path', {
576
+ type: 'TARGET_URL_PATH_FORWARDING',
577
+ base,
578
+ path,
579
+ final: fullUrl,
580
+ });
581
+ }
582
+ else if (hasMcpPath) {
583
+ // Use as-is (strip trailing slash if present, though typically not needed if strictly as-is, but good practice for consistency)
584
+ fullUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
585
+ logger_js_1.logger?.debug('Using target URL as-is', {
586
+ type: 'MCP_URL_AS_IS',
587
+ original: baseUrl,
588
+ final: fullUrl,
589
+ reason: 'path detection',
590
+ });
591
+ }
592
+ else {
593
+ // Default: append MCP path
594
+ const mcpPath = '/mcp/stream/http';
595
+ fullUrl = baseUrl.endsWith('/')
596
+ ? `${baseUrl.slice(0, -1)}${mcpPath}`
597
+ : `${baseUrl}${mcpPath}`;
598
+ logger_js_1.logger?.debug('Appended MCP path to base URL', {
599
+ type: 'MCP_URL_APPENDED',
600
+ original: baseUrl,
601
+ final: fullUrl,
602
+ });
603
+ }
604
+ const builtHeaders = {};
605
+ for (const [key, value] of Object.entries(proxyHeaders)) {
606
+ const lowerKey = key.toLowerCase();
607
+ if (lowerKey.includes('token') ||
608
+ lowerKey.includes('authorization') ||
609
+ lowerKey.includes('password') ||
610
+ lowerKey.includes('secret')) {
611
+ builtHeaders[key] = value
612
+ ? `${String(value).substring(0, 20)}...`
613
+ : '[REDACTED]';
614
+ }
615
+ else {
616
+ builtHeaders[key] = String(value || '');
617
+ }
618
+ }
619
+ const sanitizedRequestParams = {};
620
+ const requestData = originalRequest.data;
621
+ if (requestData &&
622
+ requestData.params &&
623
+ typeof requestData.params === 'object' &&
624
+ requestData.params !== null) {
625
+ const params = requestData.params;
626
+ // ... (existing sanitization logic) ...
627
+ if (params.arguments &&
628
+ typeof params.arguments === 'object' &&
629
+ params.arguments !== null) {
630
+ sanitizedRequestParams.arguments = {};
631
+ const sanitizedArgs = sanitizedRequestParams.arguments;
632
+ for (const [key, value] of Object.entries(params.arguments)) {
633
+ const lowerKey = key.toLowerCase();
634
+ if (lowerKey.includes('password') ||
635
+ lowerKey.includes('token') ||
636
+ lowerKey.includes('secret')) {
637
+ sanitizedArgs[key] = '[REDACTED]';
638
+ }
639
+ else {
640
+ sanitizedArgs[key] = value;
641
+ }
642
+ }
643
+ }
644
+ for (const [key, value] of Object.entries(params)) {
645
+ if (key === 'arguments')
646
+ continue;
647
+ sanitizedRequestParams[key] = value;
648
+ }
649
+ }
650
+ logger_js_1.logger?.info('=== BUILDING PROXY REQUEST ===', {
651
+ type: 'PROXY_REQUEST_BUILT',
652
+ btpDestination: btpDestination,
653
+ url: fullUrl,
654
+ headers: builtHeaders,
655
+ baseUrl,
656
+ fullUrl,
657
+ hasAuthToken: !!proxyHeaders[interfaces_1.HEADER_AUTHORIZATION],
658
+ btpSource: btpDestinationHeader
659
+ ? 'header'
660
+ : btpDestinationFromParam
661
+ ? 'parameter'
662
+ : 'none',
663
+ requestMethod: originalRequest.method,
664
+ requestId: originalRequest.id,
665
+ requestParams: sanitizedRequestParams,
666
+ });
667
+ // Return axios config with full URL
668
+ // Use original request method
669
+ const axiosConfig = {
670
+ method: originalRequest.method,
671
+ url: fullUrl,
672
+ headers: proxyHeaders,
673
+ data: originalRequest.data, // Send the original data (body)
674
+ };
675
+ logger_js_1.logger?.debug('Axios request config', {
676
+ type: 'AXIOS_REQUEST_CONFIG',
677
+ method: axiosConfig.method,
678
+ url: axiosConfig.url,
679
+ headers: Object.keys(axiosConfig.headers),
680
+ dataKeys: originalRequest ? Object.keys(originalRequest) : [],
681
+ });
682
+ return axiosConfig;
683
+ }
684
+ /**
685
+ * Proxy MCP request to target server with retry, circuit breaker, and error handling
686
+ */
687
+ async proxyRequest(originalRequest, routingDecision, originalHeaders) {
688
+ // Check circuit breaker
689
+ if (!this.circuitBreaker.canProceed()) {
690
+ logger_js_1.logger?.warn('Circuit breaker is open, rejecting request', {
691
+ type: 'CIRCUIT_BREAKER_REJECTED',
692
+ btpDestination: routingDecision.btpDestination,
693
+ });
694
+ return (0, errorHandler_js_1.createErrorResponse)(originalRequest.id || null, -32001, 'Service temporarily unavailable (circuit breaker open)', { circuitBreakerState: this.circuitBreaker.getState() });
695
+ }
696
+ const retryOptions = {
697
+ maxRetries: this.config.maxRetries || 3,
698
+ retryDelay: this.config.retryDelay || 1000,
699
+ retryableStatusCodes: [500, 502, 503, 504],
700
+ };
701
+ try {
702
+ const response = await (0, errorHandler_js_1.retryWithBackoff)(async () => {
703
+ const forceTokenRefresh = false;
704
+ const proxyConfig = await this.buildProxyRequest(originalRequest, routingDecision, originalHeaders, forceTokenRefresh);
705
+ // Log outgoing request details
706
+ const outgoingHeaders = {};
707
+ for (const [key, value] of Object.entries(proxyConfig.headers || {})) {
708
+ const lowerKey = key.toLowerCase();
709
+ if (lowerKey.includes('token') ||
710
+ lowerKey.includes('authorization') ||
711
+ lowerKey.includes('password') ||
712
+ lowerKey.includes('secret')) {
713
+ outgoingHeaders[key] = value
714
+ ? `${String(value).substring(0, 20)}...`
715
+ : '[REDACTED]';
716
+ }
717
+ else {
718
+ outgoingHeaders[key] = String(value || '');
719
+ }
720
+ }
721
+ const sanitizedOutgoingBody = {};
722
+ if (proxyConfig.data && typeof proxyConfig.data === 'object') {
723
+ if (proxyConfig.data.params &&
724
+ typeof proxyConfig.data.params === 'object' &&
725
+ proxyConfig.data.params !== null) {
726
+ const params = proxyConfig.data.params;
727
+ sanitizedOutgoingBody.params = {};
728
+ const sanitizedParams = sanitizedOutgoingBody.params;
729
+ if (params.arguments &&
730
+ typeof params.arguments === 'object' &&
731
+ params.arguments !== null) {
732
+ sanitizedParams.arguments = {};
733
+ const sanitizedArgs = sanitizedParams.arguments;
734
+ for (const [key, value] of Object.entries(params.arguments)) {
735
+ const lowerKey = key.toLowerCase();
736
+ if (lowerKey.includes('password') ||
737
+ lowerKey.includes('token') ||
738
+ lowerKey.includes('secret')) {
739
+ sanitizedArgs[key] = '[REDACTED]';
740
+ }
741
+ else {
742
+ sanitizedArgs[key] = value;
743
+ }
744
+ }
745
+ }
746
+ for (const [key, value] of Object.entries(params)) {
747
+ if (key === 'arguments')
748
+ continue;
749
+ sanitizedParams[key] = value;
750
+ }
751
+ }
752
+ sanitizedOutgoingBody.method = proxyConfig.data.method;
753
+ sanitizedOutgoingBody.id = proxyConfig.data.id;
754
+ sanitizedOutgoingBody.jsonrpc = proxyConfig.data.jsonrpc;
755
+ }
756
+ logger_js_1.logger?.info('=== SENDING PROXY REQUEST ===', {
757
+ type: 'PROXY_REQUEST_SENDING',
758
+ url: proxyConfig.url,
759
+ headers: outgoingHeaders,
760
+ body: sanitizedOutgoingBody,
761
+ });
762
+ const response = await this.axiosInstance.request(proxyConfig);
763
+ this.circuitBreaker.recordSuccess();
764
+ return response;
765
+ }, retryOptions);
766
+ // Return response in JSON-RPC format
767
+ return {
768
+ jsonrpc: '2.0',
769
+ id: originalRequest.id || null,
770
+ result: response.data.result,
771
+ error: response.data.error,
772
+ };
773
+ }
774
+ catch (error) {
775
+ if ((0, errorHandler_js_1.isTokenExpirationError)(error)) {
776
+ logger_js_1.logger?.warn('Token expired usually handled by retry with refresh', {
777
+ type: 'TOKEN_EXPIRED_ERROR',
778
+ });
779
+ }
780
+ this.circuitBreaker.recordFailure();
781
+ let statusCode = 500;
782
+ let errorMessage = 'Internal Server Error';
783
+ let errorData;
784
+ if (axios_1.default.isAxiosError(error)) {
785
+ statusCode = error.response?.status || 500;
786
+ errorMessage = error.message;
787
+ errorData = error.response?.data;
788
+ logger_js_1.logger?.error('Axios error in proxy request', {
789
+ type: 'AXIOS_PROXY_ERROR',
790
+ status: statusCode,
791
+ message: errorMessage,
792
+ data: errorData,
793
+ url: error.config?.url,
794
+ });
795
+ }
796
+ else {
797
+ errorMessage = error instanceof Error ? error.message : String(error);
798
+ logger_js_1.logger?.error('Generic error in proxy request', {
799
+ type: 'GENERIC_PROXY_ERROR',
800
+ message: errorMessage,
801
+ });
802
+ }
803
+ return (0, errorHandler_js_1.createErrorResponse)(originalRequest.id || null, -32000, `Proxy error: ${errorMessage}`, errorData);
804
+ }
805
+ }
806
+ /**
807
+ * Create a new BtpProxy instance
808
+ */
809
+ static async create(config) {
810
+ const loadedConfig = config ? { ...(0, config_js_1.loadConfig)(), ...config } : (0, config_js_1.loadConfig)();
811
+ const unsafeIndex = loadedConfig.unsafe ?? false;
812
+ const { serviceKeyStore, sessionStore } = await (0, stores_js_1.getPlatformStores)(unsafeIndex);
813
+ const defaultBtpAuthBroker = new auth_broker_1.AuthBroker({
814
+ serviceKeyStore,
815
+ sessionStore,
816
+ tokenProvider: new auth_providers_1.AuthorizationCodeProvider({
817
+ uaaUrl: 'https://placeholder.authentication.sap.hana.ondemand.com',
818
+ clientId: 'placeholder',
819
+ clientSecret: 'placeholder',
820
+ browser: loadedConfig.browser,
821
+ redirectPort: loadedConfig.browserAuthPort,
822
+ }),
823
+ }, 'none', loggerAdapter);
824
+ return new BtpProxy(defaultBtpAuthBroker, loadedConfig);
825
+ }
826
+ }
827
+ exports.BtpProxy = BtpProxy;
828
+ /**
829
+ * Factory function to create BtpProxy
830
+ */
831
+ async function createBtpProxy(config) {
832
+ return BtpProxy.create(config);
833
+ }