@mcp-abap-adt/proxy 2.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -0
- package/LICENSE +669 -17
- package/README.md +79 -8
- package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -97
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +9 -1
- package/dist/lib/envInterpolation.d.ts +14 -2
- package/dist/lib/envInterpolation.d.ts.map +1 -1
- package/dist/lib/envInterpolation.js +34 -7
- package/dist/lib/stores.d.ts +23 -2
- package/dist/lib/stores.d.ts.map +1 -1
- package/dist/lib/stores.js +56 -1
- package/dist/mcp/cli.d.ts +2 -0
- package/dist/mcp/cli.d.ts.map +1 -0
- package/dist/mcp/cli.js +21 -0
- package/dist/mcp/configs.d.ts +33 -0
- package/dist/mcp/configs.d.ts.map +1 -0
- package/dist/mcp/configs.js +142 -0
- package/dist/mcp/ports.d.ts +15 -0
- package/dist/mcp/ports.d.ts.map +1 -0
- package/dist/mcp/ports.js +35 -0
- package/dist/mcp/registry.d.ts +57 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +145 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +79 -0
- package/dist/mcp/shutdown.d.ts +37 -0
- package/dist/mcp/shutdown.d.ts.map +1 -0
- package/dist/mcp/shutdown.js +82 -0
- package/dist/mcp/supervisor.d.ts +125 -0
- package/dist/mcp/supervisor.d.ts.map +1 -0
- package/dist/mcp/supervisor.js +330 -0
- package/dist/mcp/tools.d.ts +28 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +152 -0
- package/dist/proxy/btpProxy.d.ts +24 -73
- package/dist/proxy/btpProxy.d.ts.map +1 -1
- package/dist/proxy/btpProxy.js +65 -616
- package/dist/proxy/credentials.d.ts +45 -0
- package/dist/proxy/credentials.d.ts.map +1 -0
- package/dist/proxy/credentials.js +41 -0
- package/dist/proxy/requestHandler.d.ts +38 -0
- package/dist/proxy/requestHandler.d.ts.map +1 -0
- package/dist/proxy/requestHandler.js +73 -0
- package/dist/proxy/reverseProxy.d.ts +11 -2
- package/dist/proxy/reverseProxy.d.ts.map +1 -1
- package/dist/proxy/reverseProxy.js +52 -9
- package/dist/router/headerAnalyzer.js +2 -2
- package/dist/router/requestInterceptor.js +9 -9
- package/docs/API.md +172 -0
- package/docs/ARCHITECTURE.md +322 -0
- package/docs/CLIENT_SETUP.md +413 -0
- package/docs/CONFIGURATION.md +258 -0
- package/docs/MIGRATION-4.0.md +125 -0
- package/docs/ROUTING_LOGIC.md +126 -0
- package/docs/TROUBLESHOOTING.md +488 -0
- package/docs/USAGE.md +422 -0
- package/docs/YAML_CONFIG.md +290 -0
- package/docs/mcp-proxy-config.example.yaml +62 -0
- package/package.json +17 -10
- package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
- package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
- package/dist/proxy/cloudLlmHubProxy.js +0 -3
package/dist/proxy/btpProxy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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
|
|
348
|
-
logger_js_1.logger?.
|
|
349
|
-
type: '
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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:
|
|
333
|
+
error: message,
|
|
427
334
|
});
|
|
428
335
|
if (shouldWriteStderr()) {
|
|
429
|
-
process.stderr.write(`[MCP Proxy] ✗ ${
|
|
336
|
+
process.stderr.write(`[MCP Proxy] ✗ ${message}\n`);
|
|
430
337
|
}
|
|
431
|
-
throw new Error(
|
|
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
|
-
*
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
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
|
-
|
|
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
|
*/
|