@mcp-abap-adt/proxy 1.6.4 → 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 +217 -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 +116 -631
  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,32 @@
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");
18
+ /**
19
+ * Default port for the local OAuth2 callback server, kept away from the
20
+ * proxy's own default HTTP port (3001). auth-providers@2.0.0's own default
21
+ * (61001) is deliberately not inherited here: this port is documented as
22
+ * `--browser-auth-port` and may be registered with the identity provider, so
23
+ * silently moving it would break every proxy already relying on 3333.
24
+ */
25
+ const DEFAULT_BROWSER_AUTH_PORT = 3333;
26
+ /**
27
+ * auth-providers@2.0.0's `browserCallbackStrategy` defaults `timeoutMs` to 30
28
+ * seconds — sized for an unattended caller. Here the login is driven by a
29
+ * person: a browser tab has to open, and they may need to type credentials or
30
+ * complete MFA before the redirect lands. Five minutes gives them room to do
31
+ * that without the callback server tearing itself down mid-login.
32
+ */
33
+ const INTERACTIVE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
22
34
  /**
23
35
  * Adapter to convert proxy Logger to ILogger interface expected by AuthBroker
24
36
  */
@@ -54,14 +66,9 @@ function shouldWriteStderr() {
54
66
  * BTP Proxy Client
55
67
  */
56
68
  class BtpProxy {
57
- axiosInstance;
58
69
  defaultBtpAuthBroker;
59
70
  btpAuthBrokers = new Map();
60
- tokenCache = new Map();
61
- refreshTimers = new Map();
62
- TOKEN_CACHE_TTL = 30 * 60 * 1000; // fallback if JWT has no exp
63
- REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
64
- circuitBreaker;
71
+ credentials;
65
72
  config;
66
73
  unsafe;
67
74
  constructor(defaultBtpAuthBroker, config) {
@@ -84,53 +91,15 @@ class BtpProxy {
84
91
  type: 'CONFIG_FALLBACK',
85
92
  });
86
93
  }
87
- // Initialize circuit breaker
88
- this.circuitBreaker = new errorHandler_js_1.CircuitBreaker(this.config.circuitBreakerThreshold || 5, this.config.circuitBreakerTimeout || 60000);
89
- // Create axios instance without baseURL - we'll use full URLs from BTP destination
90
- const https = require('node:https');
91
- this.axiosInstance = axios_1.default.create({
92
- timeout: this.config.requestTimeout || 60000,
93
- headers: {
94
- [interfaces_1.HEADER_CONTENT_TYPE]: 'application/json',
95
- },
96
- httpsAgent: new https.Agent({
97
- rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
98
- keepAlive: true,
99
- }),
100
- });
101
- // Add request interceptor for logging
102
- this.axiosInstance.interceptors.request.use((config) => {
103
- logger_js_1.logger?.debug('Proxying request to target service', {
104
- type: 'PROXY_REQUEST',
105
- method: config.method,
106
- url: config.url,
107
- baseURL: config.baseURL,
108
- });
109
- return config;
110
- }, (error) => {
111
- logger_js_1.logger?.error('Request interceptor error', {
112
- type: 'MCP_PROXY_REQUEST_ERROR',
113
- error: error instanceof Error ? error.message : String(error),
114
- });
115
- return Promise.reject(error);
116
- });
117
- // Add response interceptor for logging
118
- this.axiosInstance.interceptors.response.use((response) => {
119
- logger_js_1.logger?.debug('Received response from target service', {
120
- type: 'PROXY_RESPONSE',
121
- status: response.status,
122
- url: response.config.url,
123
- });
124
- return response;
125
- }, (error) => {
126
- logger_js_1.logger?.error('Response interceptor error', {
127
- type: 'MCP_PROXY_RESPONSE_ERROR',
128
- error: error instanceof Error ? error.message : String(error),
129
- status: error.response?.status,
130
- url: error.config?.url,
131
- });
132
- return Promise.reject(error);
133
- });
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.
134
103
  }
135
104
  /**
136
105
  * Initialize authentication for destination (eager auth)
@@ -143,7 +112,7 @@ class BtpProxy {
143
112
  });
144
113
  try {
145
114
  // Just getting the token will trigger authentication
146
- await this.getJwtToken(destination);
115
+ await this.getAuthorizationHeader(destination);
147
116
  logger_js_1.logger?.info('BTP proxy authentication initialized successfully', {
148
117
  type: 'BTP_PROXY_INIT_SUCCESS',
149
118
  destination,
@@ -211,21 +180,28 @@ class BtpProxy {
211
180
  }
212
181
  // Always use AuthorizationCodeProvider (enforced)
213
182
  // Map IAuthorizationConfig (uaaClientId) to ProviderConfig (clientId)
214
- // Default redirectPort to 3333 to avoid conflict with main proxy port (default 3001)
183
+ // How the login is conducted is now a strategy the provider config
184
+ // carries under `authorization`; the callback port lives inside it.
185
+ //
186
+ // uaaUrl/clientId/clientSecret fall back to '' rather than being omitted
187
+ // when authConfig is missing: AuthorizationCodeProviderConfig declares
188
+ // them required, and the provider's own constructor already treats an
189
+ // empty string the same as absent, throwing its "Missing required
190
+ // fields" ValidationError — which is the clear failure this was always
191
+ // meant to produce.
215
192
  const providerConfig = {
216
- browser: this.config.browser,
217
- redirectPort: this.config.browserAuthPort || 3333,
218
- // Pass a logger so browserAuth can surface the "Open this URL" prompt in
219
- // 'none'/'headless' mode. Without it, startBrowserAuth gets a null logger
220
- // and silently drops the authorization URL.
193
+ authorization: (0, auth_providers_1.browserCallbackStrategy)({
194
+ browser: this.config.browser,
195
+ port: this.config.browserAuthPort || DEFAULT_BROWSER_AUTH_PORT,
196
+ timeoutMs: INTERACTIVE_LOGIN_TIMEOUT_MS,
197
+ }),
198
+ // Pass a logger so the callback strategy can surface the "Open this
199
+ // URL" prompt in 'none'/'headless' mode. Without it, the callback
200
+ // server gets a null logger and silently drops the authorization URL.
221
201
  logger: loggerAdapter,
222
- ...(authConfig
223
- ? {
224
- uaaUrl: authConfig.uaaUrl,
225
- clientId: authConfig.uaaClientId,
226
- clientSecret: authConfig.uaaClientSecret,
227
- }
228
- : {}),
202
+ uaaUrl: authConfig?.uaaUrl ?? '',
203
+ clientId: authConfig?.uaaClientId ?? '',
204
+ clientSecret: authConfig?.uaaClientSecret ?? '',
229
205
  };
230
206
  const tokenProvider = new auth_providers_1.AuthorizationCodeProvider(providerConfig);
231
207
  broker = new auth_broker_1.AuthBroker({
@@ -317,170 +293,81 @@ class BtpProxy {
317
293
  return broker;
318
294
  }
319
295
  /**
320
- * Get JWT token for BTP destination from auth-broker with retry and token refresh
321
- * @param destination Destination name
322
- * @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.
323
311
  */
324
- async getJwtToken(destination, forceRefresh = false) {
325
- logger_js_1.logger?.info('Getting JWT token', {
326
- 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',
327
315
  destination,
328
316
  });
329
- const authBroker = await this.getOrCreateBtpAuthBroker(destination);
330
- // Check cache first (unless force refresh)
331
- if (!forceRefresh) {
332
- const cached = this.tokenCache.get(destination);
333
- if (cached && cached.expiresAt > Date.now()) {
334
- logger_js_1.logger?.debug('Using cached JWT token', {
335
- type: 'JWT_TOKEN_CACHE_HIT',
336
- destination,
337
- });
338
- return cached.token;
339
- }
340
- }
341
- // Retry logic for token retrieval
342
317
  const retryOptions = {
343
318
  maxRetries: this.config.maxRetries || 3,
344
319
  retryDelay: this.config.retryDelay || 1000,
345
320
  retryableStatusCodes: [500, 502, 503, 504],
346
321
  };
347
322
  try {
348
- const token = await (0, errorHandler_js_1.retryWithBackoff)(async () => {
349
- // Clear cache if force refresh
350
- if (forceRefresh) {
351
- this.tokenCache.delete(destination);
352
- }
353
- logger_js_1.logger?.debug('Getting JWT token from auth-broker', {
354
- type: 'JWT_TOKEN_REQUEST_START',
355
- destination,
356
- forceRefresh,
357
- });
358
- // Get token from auth-broker
359
- const token = await authBroker.getToken(destination);
360
- this.cacheToken(destination, token);
361
- logger_js_1.logger?.debug('Retrieved JWT token from auth-broker', {
362
- type: 'JWT_TOKEN_RETRIEVED',
363
- destination,
364
- forceRefresh,
365
- });
366
- return token;
323
+ return await (0, errorHandler_js_1.retryWithBackoff)(async () => {
324
+ const { credential } = await this.credentials.get(destination);
325
+ return credential.authorizationHeader();
367
326
  }, retryOptions);
368
- return token;
369
327
  }
370
328
  catch (error) {
371
- let errorMessage = error instanceof Error ? error.message : String(error);
372
- // Rewrite error message to remove .env file references (proxy only uses service keys)
373
- if (errorMessage.includes('.env') || errorMessage.includes('mcp.env')) {
374
- const searchedInMatch = errorMessage.match(/Searched in:\s*([\s\S]*?)(?:\n|$)/);
375
- const searchedPaths = searchedInMatch
376
- ? searchedInMatch[1]
377
- .trim()
378
- .split('\n')
379
- .map((p) => p.trim().replace(/^-\s*/, ''))
380
- .filter((p) => p)
381
- : [];
382
- errorMessage =
383
- `Service key file not found for destination "${destination}".\n` +
384
- `Please create service key file: ${destination}.json\n`;
385
- if (searchedPaths.length > 0) {
386
- errorMessage += `Searched in:\n`;
387
- searchedPaths.forEach((path) => {
388
- errorMessage += ` - ${path}\n`;
389
- });
390
- }
391
- else {
392
- const isWindows = process.platform === 'win32';
393
- const homeDir = require('node:os').homedir();
394
- const defaultPath = isWindows
395
- ? require('node:path').join(homeDir, 'Documents', 'mcp-abap-adt', 'service-keys')
396
- : require('node:path').join(homeDir, '.config', 'mcp-abap-adt', 'service-keys');
397
- errorMessage += `Searched in:\n - ${defaultPath}\n`;
398
- }
399
- }
400
- logger_js_1.logger?.error('Failed to get JWT token from auth-broker after retries', {
401
- 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',
402
332
  destination,
403
- error: errorMessage,
333
+ error: message,
404
334
  });
405
335
  if (shouldWriteStderr()) {
406
- process.stderr.write(`[MCP Proxy] ✗ ${errorMessage}`);
336
+ process.stderr.write(`[MCP Proxy] ✗ ${message}\n`);
407
337
  }
408
- throw new Error(errorMessage);
338
+ throw new Error(message);
409
339
  }
410
340
  }
411
341
  /**
412
- * Decode JWT exp claim (seconds since epoch). Returns null if not parseable.
413
- */
414
- decodeJwtExp(token) {
415
- const parts = token.split('.');
416
- if (parts.length < 2)
417
- return null;
418
- try {
419
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
420
- return typeof payload.exp === 'number' ? payload.exp : null;
421
- }
422
- catch {
423
- return null;
424
- }
425
- }
426
- /**
427
- * Cache token with expiry derived from JWT, and schedule proactive refresh
428
- * 5 minutes before expiry.
429
- */
430
- cacheToken(destination, token) {
431
- const exp = this.decodeJwtExp(token);
432
- const expiresAt = exp !== null ? exp * 1000 : Date.now() + this.TOKEN_CACHE_TTL;
433
- this.tokenCache.set(destination, { token, expiresAt });
434
- this.scheduleProactiveRefresh(destination, expiresAt);
435
- }
436
- /**
437
- * Schedule background refresh REFRESH_LEAD_MS before token expiry.
438
- */
439
- scheduleProactiveRefresh(destination, expiresAt) {
440
- const existing = this.refreshTimers.get(destination);
441
- if (existing)
442
- clearTimeout(existing);
443
- const delay = expiresAt - Date.now() - this.REFRESH_LEAD_MS;
444
- if (delay <= 0) {
445
- // Token already within lead window or expired; skip scheduling.
446
- this.refreshTimers.delete(destination);
447
- return;
448
- }
449
- const timer = setTimeout(() => {
450
- this.refreshTimers.delete(destination);
451
- this.proactiveRefresh(destination).catch((err) => {
452
- logger_js_1.logger?.warn('Proactive token refresh failed, will retry reactively', {
453
- type: 'JWT_TOKEN_PROACTIVE_REFRESH_ERROR',
454
- destination,
455
- error: err instanceof Error ? err.message : String(err),
456
- });
457
- });
458
- }, delay);
459
- // Don't keep the event loop alive just for refresh timers.
460
- timer.unref?.();
461
- this.refreshTimers.set(destination, timer);
462
- }
463
- /**
464
- * 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.
465
348
  */
466
- async proactiveRefresh(destination) {
467
- logger_js_1.logger?.info('Proactively refreshing JWT token', {
468
- type: 'JWT_TOKEN_PROACTIVE_REFRESH',
469
- destination,
470
- });
471
- const broker = await this.getOrCreateBtpAuthBroker(destination);
472
- const token = await broker.refreshToken(destination);
473
- 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');
474
368
  }
475
- /**
476
- * Cancel all pending refresh timers. Call on shutdown.
477
- */
478
369
  dispose() {
479
- for (const timer of this.refreshTimers.values()) {
480
- clearTimeout(timer);
481
- }
482
- this.refreshTimers.clear();
483
- this.tokenCache.clear();
370
+ this.credentials.clear();
484
371
  this.btpAuthBrokers.clear();
485
372
  }
486
373
  /**
@@ -499,421 +386,6 @@ class BtpProxy {
499
386
  throw new Error(`No target URL found for destination "${destination}". ` +
500
387
  `Set targetUrl in config or ensure service key contains abap.url.`);
501
388
  }
502
- /**
503
- * Helper function to extract string value from header (handles arrays)
504
- */
505
- getHeaderValue(headerValue) {
506
- if (!headerValue)
507
- return undefined;
508
- if (Array.isArray(headerValue)) {
509
- return headerValue[0]?.trim();
510
- }
511
- return typeof headerValue === 'string' ? headerValue.trim() : undefined;
512
- }
513
- /**
514
- * Build proxy request with JWT token (BTP authentication only)
515
- *
516
- * Process flow:
517
- *
518
- * 1. BTP Authentication (XSUAA):
519
- * 1.1 If x-sap-destination header exists:
520
- * - Check map for broker with key = destination, get or create, save to map
521
- * - Get token from xsuaa broker
522
- * - Add/replace Authorization: Bearer <token> header
523
- * 1.2 If header doesn't exist but --btp parameter exists:
524
- * - Use destination from parameter, get or create broker, save to map
525
- * - Get token from xsuaa broker
526
- * - Add/replace Authorization: Bearer <token> header
527
- * 1.3 If neither header nor parameter:
528
- * - Do nothing, pass request further
529
- */
530
- async buildProxyRequest(originalRequest, routingDecision, originalHeaders, forceTokenRefresh = false) {
531
- logger_js_1.logger?.info('=== BUILD PROXY REQUEST - ORIGINAL ===', {
532
- type: 'BUILD_PROXY_REQUEST_ORIGINAL',
533
- originalRequestId: originalRequest.id,
534
- originalRequestIdType: typeof originalRequest.id,
535
- originalRequestMethod: originalRequest.method,
536
- fullOriginalRequest: JSON.stringify(originalRequest),
537
- });
538
- // Build headers for target MCP server
539
- const proxyHeaders = {
540
- [interfaces_1.HEADER_CONTENT_TYPE]: 'application/json',
541
- [interfaces_1.HEADER_ACCEPT]: 'application/json, application/x-ndjson, text/event-stream',
542
- };
543
- // ============================================
544
- // BTP Authentication (XSUAA)
545
- // ============================================
546
- const btpDestinationHeader = this.getHeaderValue(originalHeaders[interfaces_1.HEADER_SAP_DESTINATION]);
547
- const btpDestinationFromParam = routingDecision.btpDestination && !btpDestinationHeader
548
- ? routingDecision.btpDestination
549
- : undefined;
550
- let btpDestination;
551
- if (btpDestinationHeader) {
552
- btpDestination = btpDestinationHeader;
553
- logger_js_1.logger?.debug('Using x-sap-destination from header', {
554
- type: 'BTP_DESTINATION_FROM_HEADER',
555
- destination: btpDestination,
556
- });
557
- }
558
- else if (btpDestinationFromParam) {
559
- btpDestination = btpDestinationFromParam;
560
- logger_js_1.logger?.debug('Using x-sap-destination from parameter', {
561
- type: 'BTP_DESTINATION_FROM_PARAM',
562
- destination: btpDestination,
563
- });
564
- }
565
- if (btpDestination) {
566
- const _btpBroker = await this.getOrCreateBtpAuthBroker(btpDestination);
567
- const existingAuth = originalHeaders[interfaces_1.HEADER_AUTHORIZATION.toLowerCase()] ||
568
- originalHeaders[interfaces_1.HEADER_AUTHORIZATION];
569
- const hasExistingAuth = !!existingAuth;
570
- const authToken = await this.getJwtToken(btpDestination, forceTokenRefresh);
571
- proxyHeaders[interfaces_1.HEADER_AUTHORIZATION] = `Bearer ${authToken}`;
572
- logger_js_1.logger?.debug(hasExistingAuth
573
- ? 'Replaced existing Authorization header with BTP token'
574
- : 'Added BTP Cloud authorization token', {
575
- type: hasExistingAuth
576
- ? 'BTP_AUTH_TOKEN_REPLACED'
577
- : 'BTP_AUTH_TOKEN_ADDED',
578
- destination: btpDestination,
579
- hadExistingAuth: hasExistingAuth,
580
- });
581
- }
582
- else {
583
- logger_js_1.logger?.debug('No BTP destination - skipping authentication', {
584
- type: 'BTP_AUTH_SKIPPED',
585
- });
586
- }
587
- // Preserve other SAP headers if provided by client
588
- const sapHeaders = [interfaces_1.HEADER_SAP_CLIENT, interfaces_1.HEADER_SAP_DESTINATION_SERVICE];
589
- for (const headerName of sapHeaders) {
590
- const value = originalHeaders[headerName];
591
- if (value) {
592
- proxyHeaders[headerName] = Array.isArray(value) ? value[0] : value;
593
- }
594
- }
595
- // Get MCP server URL from BTP destination service key
596
- let baseUrl;
597
- if (btpDestination) {
598
- try {
599
- const btpBroker = await this.getOrCreateBtpAuthBroker(btpDestination, routingDecision.targetUrl);
600
- const connConfig = await btpBroker.getConnectionConfig(btpDestination);
601
- // Use service key URL as base (if available)
602
- if (connConfig?.serviceUrl) {
603
- baseUrl = connConfig.serviceUrl;
604
- logger_js_1.logger?.debug('Using MCP URL from BTP destination service key', {
605
- type: 'MCP_URL_FROM_BTP_DESTINATION',
606
- destination: btpDestination,
607
- url: baseUrl,
608
- });
609
- }
610
- else {
611
- logger_js_1.logger?.warn('BTP destination service key does not contain service URL', {
612
- type: 'BTP_DESTINATION_NO_URL',
613
- destination: btpDestination,
614
- });
615
- }
616
- }
617
- catch (error) {
618
- logger_js_1.logger?.warn('Failed to get URL from BTP destination service key', {
619
- type: 'BTP_DESTINATION_URL_ERROR',
620
- destination: btpDestination,
621
- error: error instanceof Error ? error.message : String(error),
622
- });
623
- }
624
- }
625
- // Apply Target URL override logic
626
- if (routingDecision.targetUrl) {
627
- if (baseUrl) {
628
- logger_js_1.logger?.debug('Overriding service key URL with explicit target URL', {
629
- type: 'TARGET_URL_OVERRIDE',
630
- url: routingDecision.targetUrl,
631
- serviceKeyUrl: baseUrl,
632
- });
633
- }
634
- else {
635
- logger_js_1.logger?.debug('Using explicit target URL from configuration', {
636
- type: 'TARGET_URL_FROM_CONFIG',
637
- url: routingDecision.targetUrl,
638
- });
639
- }
640
- baseUrl = routingDecision.targetUrl;
641
- }
642
- if (!baseUrl) {
643
- throw new Error('Cannot determine target URL: provide btpDestination with service key containing URL OR use --target-url');
644
- }
645
- // Construct full MCP endpoint URL
646
- let fullUrl;
647
- // Determine the path to append
648
- // If original request has a URL/path, use it. Otherwise default to nothing/root.
649
- const requestPath = originalRequest.url || '';
650
- // Check if we should use URL as-is
651
- // 1. If it already looks like an MCP URL
652
- // 2. OR if it was explicitly provided via targetUrl (user knows best)
653
- const isExplicitTarget = !!routingDecision.targetUrl;
654
- const hasMcpPath = baseUrl.includes('/mcp/') ||
655
- baseUrl.endsWith('/mcp') ||
656
- baseUrl.includes('/mcp/stream/');
657
- if (isExplicitTarget) {
658
- // Explicit Target URL logic (as per User request):
659
- // "whatever endpoint [proxy] received, it adds to the target URL"
660
- // Use join logic to avoid double slashes and ensure correctness
661
- // Remove trailing slash from base
662
- const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
663
- // Ensure path starts with slash if not empty
664
- const path = requestPath.startsWith('/')
665
- ? requestPath
666
- : `/${requestPath}`;
667
- // If requestPath is empty or just '/', ensure we don't end up with empty path if base is root
668
- // But typically we just append.
669
- fullUrl = `${base}${path}`;
670
- logger_js_1.logger?.debug('Using explicit target URL with forwarded path', {
671
- type: 'TARGET_URL_PATH_FORWARDING',
672
- base,
673
- path,
674
- final: fullUrl,
675
- });
676
- }
677
- else if (hasMcpPath) {
678
- // Use as-is (strip trailing slash if present, though typically not needed if strictly as-is, but good practice for consistency)
679
- fullUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
680
- logger_js_1.logger?.debug('Using target URL as-is', {
681
- type: 'MCP_URL_AS_IS',
682
- original: baseUrl,
683
- final: fullUrl,
684
- reason: 'path detection',
685
- });
686
- }
687
- else {
688
- // Default: append MCP path
689
- const mcpPath = '/mcp/stream/http';
690
- fullUrl = baseUrl.endsWith('/')
691
- ? `${baseUrl.slice(0, -1)}${mcpPath}`
692
- : `${baseUrl}${mcpPath}`;
693
- logger_js_1.logger?.debug('Appended MCP path to base URL', {
694
- type: 'MCP_URL_APPENDED',
695
- original: baseUrl,
696
- final: fullUrl,
697
- });
698
- }
699
- const builtHeaders = {};
700
- for (const [key, value] of Object.entries(proxyHeaders)) {
701
- const lowerKey = key.toLowerCase();
702
- if (lowerKey.includes('token') ||
703
- lowerKey.includes('authorization') ||
704
- lowerKey.includes('password') ||
705
- lowerKey.includes('secret')) {
706
- builtHeaders[key] = value
707
- ? `${String(value).substring(0, 20)}...`
708
- : '[REDACTED]';
709
- }
710
- else {
711
- builtHeaders[key] = String(value || '');
712
- }
713
- }
714
- const sanitizedRequestParams = {};
715
- const requestData = originalRequest.data;
716
- if (requestData &&
717
- requestData.params &&
718
- typeof requestData.params === 'object' &&
719
- requestData.params !== null) {
720
- const params = requestData.params;
721
- // ... (existing sanitization logic) ...
722
- if (params.arguments &&
723
- typeof params.arguments === 'object' &&
724
- params.arguments !== null) {
725
- sanitizedRequestParams.arguments = {};
726
- const sanitizedArgs = sanitizedRequestParams.arguments;
727
- for (const [key, value] of Object.entries(params.arguments)) {
728
- const lowerKey = key.toLowerCase();
729
- if (lowerKey.includes('password') ||
730
- lowerKey.includes('token') ||
731
- lowerKey.includes('secret')) {
732
- sanitizedArgs[key] = '[REDACTED]';
733
- }
734
- else {
735
- sanitizedArgs[key] = value;
736
- }
737
- }
738
- }
739
- for (const [key, value] of Object.entries(params)) {
740
- if (key === 'arguments')
741
- continue;
742
- sanitizedRequestParams[key] = value;
743
- }
744
- }
745
- logger_js_1.logger?.info('=== BUILDING PROXY REQUEST ===', {
746
- type: 'PROXY_REQUEST_BUILT',
747
- btpDestination: btpDestination,
748
- url: fullUrl,
749
- headers: builtHeaders,
750
- baseUrl,
751
- fullUrl,
752
- hasAuthToken: !!proxyHeaders[interfaces_1.HEADER_AUTHORIZATION],
753
- btpSource: btpDestinationHeader
754
- ? 'header'
755
- : btpDestinationFromParam
756
- ? 'parameter'
757
- : 'none',
758
- requestMethod: originalRequest.method,
759
- requestId: originalRequest.id,
760
- requestParams: sanitizedRequestParams,
761
- });
762
- // Return axios config with full URL
763
- // Use original request method
764
- const axiosConfig = {
765
- method: originalRequest.method,
766
- url: fullUrl,
767
- headers: proxyHeaders,
768
- data: originalRequest.data, // Send the original data (body)
769
- };
770
- logger_js_1.logger?.debug('Axios request config', {
771
- type: 'AXIOS_REQUEST_CONFIG',
772
- method: axiosConfig.method,
773
- url: axiosConfig.url,
774
- headers: Object.keys(axiosConfig.headers),
775
- dataKeys: originalRequest ? Object.keys(originalRequest) : [],
776
- });
777
- return axiosConfig;
778
- }
779
- /**
780
- * Proxy MCP request to target server with retry, circuit breaker, and error handling
781
- */
782
- async proxyRequest(originalRequest, routingDecision, originalHeaders) {
783
- // Check circuit breaker
784
- if (!this.circuitBreaker.canProceed()) {
785
- logger_js_1.logger?.warn('Circuit breaker is open, rejecting request', {
786
- type: 'CIRCUIT_BREAKER_REJECTED',
787
- btpDestination: routingDecision.btpDestination,
788
- });
789
- return (0, errorHandler_js_1.createErrorResponse)(originalRequest.id || null, -32001, 'Service temporarily unavailable (circuit breaker open)', { circuitBreakerState: this.circuitBreaker.getState() });
790
- }
791
- const retryOptions = {
792
- maxRetries: this.config.maxRetries || 3,
793
- retryDelay: this.config.retryDelay || 1000,
794
- retryableStatusCodes: [500, 502, 503, 504],
795
- };
796
- try {
797
- let forceTokenRefresh = false;
798
- const executeRequest = async () => {
799
- const proxyConfig = await this.buildProxyRequest(originalRequest, routingDecision, originalHeaders, forceTokenRefresh);
800
- // Log outgoing request details
801
- const outgoingHeaders = {};
802
- for (const [key, value] of Object.entries(proxyConfig.headers || {})) {
803
- const lowerKey = key.toLowerCase();
804
- if (lowerKey.includes('token') ||
805
- lowerKey.includes('authorization') ||
806
- lowerKey.includes('password') ||
807
- lowerKey.includes('secret')) {
808
- outgoingHeaders[key] = value
809
- ? `${String(value).substring(0, 20)}...`
810
- : '[REDACTED]';
811
- }
812
- else {
813
- outgoingHeaders[key] = String(value || '');
814
- }
815
- }
816
- const sanitizedOutgoingBody = {};
817
- if (proxyConfig.data && typeof proxyConfig.data === 'object') {
818
- if (proxyConfig.data.params &&
819
- typeof proxyConfig.data.params === 'object' &&
820
- proxyConfig.data.params !== null) {
821
- const params = proxyConfig.data.params;
822
- sanitizedOutgoingBody.params = {};
823
- const sanitizedParams = sanitizedOutgoingBody.params;
824
- if (params.arguments &&
825
- typeof params.arguments === 'object' &&
826
- params.arguments !== null) {
827
- sanitizedParams.arguments = {};
828
- const sanitizedArgs = sanitizedParams.arguments;
829
- for (const [key, value] of Object.entries(params.arguments)) {
830
- const lowerKey = key.toLowerCase();
831
- if (lowerKey.includes('password') ||
832
- lowerKey.includes('token') ||
833
- lowerKey.includes('secret')) {
834
- sanitizedArgs[key] = '[REDACTED]';
835
- }
836
- else {
837
- sanitizedArgs[key] = value;
838
- }
839
- }
840
- }
841
- for (const [key, value] of Object.entries(params)) {
842
- if (key === 'arguments')
843
- continue;
844
- sanitizedParams[key] = value;
845
- }
846
- }
847
- sanitizedOutgoingBody.method = proxyConfig.data.method;
848
- sanitizedOutgoingBody.id = proxyConfig.data.id;
849
- sanitizedOutgoingBody.jsonrpc = proxyConfig.data.jsonrpc;
850
- }
851
- logger_js_1.logger?.info('=== SENDING PROXY REQUEST ===', {
852
- type: 'PROXY_REQUEST_SENDING',
853
- url: proxyConfig.url,
854
- headers: outgoingHeaders,
855
- body: sanitizedOutgoingBody,
856
- });
857
- const response = await this.axiosInstance.request(proxyConfig);
858
- this.circuitBreaker.recordSuccess();
859
- return response;
860
- };
861
- const response = await (0, errorHandler_js_1.retryWithBackoff)(async () => {
862
- try {
863
- return await executeRequest();
864
- }
865
- catch (error) {
866
- if (!forceTokenRefresh && (0, errorHandler_js_1.isTokenExpirationError)(error)) {
867
- const destination = routingDecision.btpDestination ||
868
- this.getHeaderValue(originalHeaders[interfaces_1.HEADER_SAP_DESTINATION]);
869
- logger_js_1.logger?.warn('Token expired, retrying with refreshed token', {
870
- type: 'TOKEN_EXPIRED_RETRY',
871
- destination,
872
- });
873
- if (destination) {
874
- this.tokenCache.delete(destination);
875
- }
876
- forceTokenRefresh = true;
877
- return await executeRequest();
878
- }
879
- throw error;
880
- }
881
- }, retryOptions);
882
- // Return response in JSON-RPC format
883
- return {
884
- jsonrpc: '2.0',
885
- id: originalRequest.id || null,
886
- result: response.data.result,
887
- error: response.data.error,
888
- };
889
- }
890
- catch (error) {
891
- this.circuitBreaker.recordFailure();
892
- let statusCode = 500;
893
- let errorMessage = 'Internal Server Error';
894
- let errorData;
895
- if (axios_1.default.isAxiosError(error)) {
896
- statusCode = error.response?.status || 500;
897
- errorMessage = error.message;
898
- errorData = error.response?.data;
899
- logger_js_1.logger?.error('Axios error in proxy request', {
900
- type: 'AXIOS_PROXY_ERROR',
901
- status: statusCode,
902
- message: errorMessage,
903
- data: errorData,
904
- url: error.config?.url,
905
- });
906
- }
907
- else {
908
- errorMessage = error instanceof Error ? error.message : String(error);
909
- logger_js_1.logger?.error('Generic error in proxy request', {
910
- type: 'GENERIC_PROXY_ERROR',
911
- message: errorMessage,
912
- });
913
- }
914
- return (0, errorHandler_js_1.createErrorResponse)(originalRequest.id || null, -32000, `Proxy error: ${errorMessage}`, errorData);
915
- }
916
- }
917
389
  /**
918
390
  * Create a new BtpProxy instance
919
391
  */
@@ -928,8 +400,21 @@ class BtpProxy {
928
400
  uaaUrl: 'https://placeholder.authentication.sap.hana.ondemand.com',
929
401
  clientId: 'placeholder',
930
402
  clientSecret: 'placeholder',
931
- browser: loadedConfig.browser,
932
- redirectPort: loadedConfig.browserAuthPort,
403
+ authorization: (0, auth_providers_1.browserCallbackStrategy)({
404
+ browser: loadedConfig.browser,
405
+ // This call site never had its own fallback: it passed
406
+ // `redirectPort` straight through and relied on
407
+ // auth-providers@1.2.0 defaulting an omitted value to 3001
408
+ // internally — which is this proxy's own default `httpPort`
409
+ // (src/lib/config.ts). An omitted `browserAuthPort` therefore
410
+ // told the callback server to bind the port the proxy already
411
+ // listens on, failing every such login with "Port 3001 is
412
+ // already in use". Both call sites now share the one documented
413
+ // default instead of leaving this one to an invisible, colliding
414
+ // number.
415
+ port: loadedConfig.browserAuthPort || DEFAULT_BROWSER_AUTH_PORT,
416
+ timeoutMs: INTERACTIVE_LOGIN_TIMEOUT_MS,
417
+ }),
933
418
  logger: loggerAdapter,
934
419
  }),
935
420
  }, 'none', loggerAdapter);