@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +218 -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/config.d.ts.map +1 -1
  9. package/dist/lib/config.js +9 -1
  10. package/dist/lib/envInterpolation.d.ts +14 -2
  11. package/dist/lib/envInterpolation.d.ts.map +1 -1
  12. package/dist/lib/envInterpolation.js +34 -7
  13. package/dist/lib/stores.d.ts +23 -2
  14. package/dist/lib/stores.d.ts.map +1 -1
  15. package/dist/lib/stores.js +56 -1
  16. package/dist/mcp/cli.d.ts +2 -0
  17. package/dist/mcp/cli.d.ts.map +1 -0
  18. package/dist/mcp/cli.js +21 -0
  19. package/dist/mcp/configs.d.ts +33 -0
  20. package/dist/mcp/configs.d.ts.map +1 -0
  21. package/dist/mcp/configs.js +142 -0
  22. package/dist/mcp/ports.d.ts +15 -0
  23. package/dist/mcp/ports.d.ts.map +1 -0
  24. package/dist/mcp/ports.js +35 -0
  25. package/dist/mcp/registry.d.ts +57 -0
  26. package/dist/mcp/registry.d.ts.map +1 -0
  27. package/dist/mcp/registry.js +145 -0
  28. package/dist/mcp/server.d.ts +34 -0
  29. package/dist/mcp/server.d.ts.map +1 -0
  30. package/dist/mcp/server.js +79 -0
  31. package/dist/mcp/shutdown.d.ts +37 -0
  32. package/dist/mcp/shutdown.d.ts.map +1 -0
  33. package/dist/mcp/shutdown.js +82 -0
  34. package/dist/mcp/supervisor.d.ts +125 -0
  35. package/dist/mcp/supervisor.d.ts.map +1 -0
  36. package/dist/mcp/supervisor.js +330 -0
  37. package/dist/mcp/tools.d.ts +28 -0
  38. package/dist/mcp/tools.d.ts.map +1 -0
  39. package/dist/mcp/tools.js +152 -0
  40. package/dist/proxy/btpProxy.d.ts +24 -73
  41. package/dist/proxy/btpProxy.d.ts.map +1 -1
  42. package/dist/proxy/btpProxy.js +65 -616
  43. package/dist/proxy/credentials.d.ts +45 -0
  44. package/dist/proxy/credentials.d.ts.map +1 -0
  45. package/dist/proxy/credentials.js +41 -0
  46. package/dist/proxy/requestHandler.d.ts +38 -0
  47. package/dist/proxy/requestHandler.d.ts.map +1 -0
  48. package/dist/proxy/requestHandler.js +73 -0
  49. package/dist/proxy/reverseProxy.d.ts +11 -2
  50. package/dist/proxy/reverseProxy.d.ts.map +1 -1
  51. package/dist/proxy/reverseProxy.js +52 -9
  52. package/dist/router/headerAnalyzer.js +2 -2
  53. package/dist/router/requestInterceptor.js +9 -9
  54. package/docs/API.md +172 -0
  55. package/docs/ARCHITECTURE.md +322 -0
  56. package/docs/CLIENT_SETUP.md +413 -0
  57. package/docs/CONFIGURATION.md +258 -0
  58. package/docs/MIGRATION-4.0.md +125 -0
  59. package/docs/ROUTING_LOGIC.md +126 -0
  60. package/docs/TROUBLESHOOTING.md +488 -0
  61. package/docs/USAGE.md +422 -0
  62. package/docs/YAML_CONFIG.md +290 -0
  63. package/docs/mcp-proxy-config.example.yaml +62 -0
  64. package/package.json +17 -10
  65. package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
  66. package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
  67. package/dist/proxy/cloudLlmHubProxy.js +0 -3
package/README.md CHANGED
@@ -13,9 +13,10 @@ Enable local MCP clients to connect to remote MCP servers with automatic JWT tok
13
13
 
14
14
  ## Features
15
15
 
16
- - ✅ **JWT Token Management** - Automatic token retrieval, caching, and refresh via auth-broker
16
+ - ✅ **One shared credential** - the token is the auth-broker's business: it caches, knows expiry and renews behind the call. The proxy holds no token of its own
17
17
  - ✅ **Service Key Based** - MCP server URL is obtained from service key for BTP destination
18
- - ✅ **Error Handling** - Retry logic, circuit breaker, and comprehensive error handling
18
+ - ✅ **Transparent forwarding** - the client's headers go through as sent; the proxy answers for the `Authorization` header and nothing else
19
+ - ✅ **Error Handling** - token acquisition is retried with exponential backoff when the failure can get better; clear messages when it cannot
19
20
  - ✅ **Multiple Transport Modes** - HTTP, SSE, and stdio support
20
21
  - ✅ **Configuration Flexibility** - Environment variables, config files, or defaults
21
22
 
@@ -40,6 +41,58 @@ mcp-abap-adt-proxy --btp=ai
40
41
  mcp-abap-adt-proxy --btp=ai --unsafe
41
42
  ```
42
43
 
44
+ ## Two commands
45
+
46
+ The package installs two binaries, for two different jobs.
47
+
48
+ | Command | What it is for |
49
+ |---|---|
50
+ | `mcp-abap-adt-proxy` | **Be** a proxy. A client points at it and its requests are authenticated and forwarded. |
51
+ | `mcp-abap-adt-proxy-mcp` | **Manage** proxies. Speaks MCP over stdio; its tools start and stop proxies on demand. |
52
+
53
+ ### The management mode
54
+
55
+ Register it like any other MCP server:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "abap-proxy": { "command": "mcp-abap-adt-proxy-mcp" }
61
+ }
62
+ }
63
+ ```
64
+
65
+ It works from the proxy configs you already keep:
66
+
67
+ ```
68
+ ~/.config/mcp-abap-adt/proxy/<name>.yaml (Windows: Documents\mcp-abap-adt\proxy\)
69
+ ```
70
+
71
+ These are the same files `mcp-abap-adt-proxy --config <file>` takes. Starting a
72
+ proxy is therefore **choosing a name**, not assembling settings — and
73
+ credentials stay in the config, resolved through `${VAR}` interpolation, rather
74
+ than travelling through a tool call.
75
+
76
+ | Tool | What it does |
77
+ |---|---|
78
+ | `proxy_configs` | Lists the configs available, by the name `proxy_start` takes. Call it first — the names cannot be guessed. |
79
+ | `proxy_start` | Starts a proxy from one of those configs and returns the URL it bound. The **port is not taken from the config**: a free one is bound instead. |
80
+ | `proxy_stop` | Stops a proxy this session started, freeing its port and releasing its credential. Proxies started by other sessions are never touched. |
81
+ | `proxy_status` | Lists this session's proxies and any others on this machine. Records whose process has died are pruned when read, so it cannot report a ghost. |
82
+
83
+ **The config name is the unit, not the destination.** Several configs commonly
84
+ name the same `btpDestination` and differ in target URL and headers — a
85
+ destination cannot tell them apart.
86
+
87
+ **Every proxy runs inside the management process.** Closing the session — or
88
+ `SIGINT`, or `SIGTERM` — stops all of them and frees their ports. This is
89
+ deliberate: a spawned child would be orphaned by any signal the parent did not
90
+ forward and would go on holding its HTTP and OAuth callback ports.
91
+
92
+ A proxy nobody has used for 30 minutes stops itself. That is a backstop for a
93
+ client that finished and forgot, not a substitute for `proxy_stop` — which is
94
+ why the tool descriptions say so, and say it again beside the URL.
95
+
43
96
  ### Configuration
44
97
 
45
98
  The proxy supports multiple configuration methods:
@@ -156,6 +209,8 @@ The proxy uses BTP/XSUAA authentication:
156
209
 
157
210
  ## Documentation
158
211
 
212
+ - 🚚 **[Migration to 4.0](./docs/MIGRATION-4.0.md)** — the contracts leave the umbrella, an SSE response streams and carries your headers, `getJwtToken()` is replaced, the circuit breaker is gone
213
+
159
214
  - **[Client Setup Guide](./docs/CLIENT_SETUP.md)** - Step-by-step setup for Cline and GitHub Copilot
160
215
  - **[Configuration Guide](./docs/CONFIGURATION.md)** - Complete configuration reference
161
216
  - **[YAML Configuration Guide](./docs/YAML_CONFIG.md)** - Using YAML/JSON configuration files
@@ -221,7 +276,6 @@ Create `mcp-proxy-config.json`:
221
276
  "httpPort": 3001,
222
277
  "logLevel": "info",
223
278
  "maxRetries": 3,
224
- "circuitBreakerThreshold": 5,
225
279
  "unsafe": false
226
280
  }
227
281
  ```
@@ -234,10 +288,9 @@ See [Configuration Guide](./docs/CONFIGURATION.md) for complete options.
234
288
 
235
289
  ## Error Handling & Resilience
236
290
 
237
- - **Retry Logic** - Exponential backoff for failed requests
238
- - **Circuit Breaker** - Prevents cascading failures
239
- - **Token Refresh** - Automatic token refresh on expiration
240
- - **Connection Pooling** - Efficient resource management
291
+ - **Retry Logic** - exponential backoff while getting a token, for failures that can get better (5xx, network). A missing service key is not one of them and fails at once, with a message naming the file to create
292
+ - **Token Refresh** - handled by the credential, which is asked per request and renews behind the call
293
+ - **No circuit breaker** - it guarded the buffered forward that 4.0.0 removed. The forwarding path now streams, and there is nowhere to put one without buffering the response again. `circuitBreakerThreshold` and `circuitBreakerTimeout` are still accepted so existing configs load, and do nothing
241
294
  - **Request Timeouts** - Configurable timeout handling
242
295
 
243
296
  ## Requirements
@@ -277,7 +330,25 @@ See [ROADMAP.md](./ROADMAP.md) for details.
277
330
 
278
331
  ## License
279
332
 
280
- MIT
333
+ **GNU General Public License v3.0 only** (`GPL-3.0-only`).
334
+ Earlier published versions were MIT and stay MIT — a licence change is not
335
+ retroactive.
336
+
337
+ Copyright © 2025–2026 Oleksii Kyslytsia
338
+
339
+ This program is free software: you can redistribute it and/or modify it under the
340
+ terms of the GNU General Public License as published by the Free Software
341
+ Foundation, version 3.
342
+
343
+ It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
344
+ without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
345
+ PURPOSE. See [`LICENSE`](LICENSE) for the full text.
346
+
347
+ **What this means.** Running it, and using it on your own data, carries no
348
+ conditions at all. Distributing it, or a modified version of it, means passing on
349
+ the same freedoms — including the source. This is a finished tool rather than a
350
+ library to build on; the libraries it is built from are LGPL, so they can be
351
+ linked from programs under any licence.
281
352
 
282
353
  ## Links
283
354
 
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MCP ABAP ADT Proxy — management mode.
5
+ *
6
+ * Speaks MCP over stdio and exposes tools that start and stop proxies. This is
7
+ * the command a client registers when it wants to MANAGE proxies; a client that
8
+ * wants to BE proxied still points at `mcp-abap-adt-proxy`.
9
+ *
10
+ * Usage:
11
+ * mcp-abap-adt-proxy-mcp [--config <file>] [--unsafe]
12
+ */
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ function parseArgs() {
18
+ const args = process.argv.slice(2);
19
+ return {
20
+ help: args.includes('--help') || args.includes('-h'),
21
+ version: args.includes('--version') || args.includes('-v'),
22
+ };
23
+ }
24
+
25
+ function showHelp() {
26
+ const pkg = require('../package.json');
27
+ console.log(`
28
+ MCP ABAP ADT Proxy — management mode v${pkg.version}
29
+
30
+ Speaks MCP over stdio. Its tools start and stop authenticating proxies; it does
31
+ not proxy anything itself.
32
+
33
+ Usage:
34
+ mcp-abap-adt-proxy-mcp [options]
35
+
36
+ Tools offered to the client:
37
+ proxy_configs List the proxy configs on this machine, by the name
38
+ proxy_start takes. Call this first — the names cannot be
39
+ guessed.
40
+ proxy_start Start a proxy from one of those configs. The config supplies
41
+ the destination, target URL, default headers and timeouts;
42
+ the PORT does not come from it — a free one is bound, so
43
+ several proxies can run at once, and the URL returned is the
44
+ one actually bound.
45
+ proxy_stop Stop a proxy this session started, freeing its port and
46
+ releasing its credential. Proxies belonging to other sessions
47
+ are never touched.
48
+ proxy_status List this session's proxies and any others on this machine.
49
+ Records whose process has died are pruned when read.
50
+
51
+ Where the configs live:
52
+ Unix: ~/.config/mcp-abap-adt/proxy/<name>.yaml
53
+ Windows: %USERPROFILE%\\Documents\\mcp-abap-adt\\proxy\\<name>.yaml
54
+
55
+ The same files 'mcp-abap-adt-proxy --config <file>' takes. Credentials stay
56
+ in them, resolved through \${VAR} interpolation, and are never passed
57
+ through a tool call.
58
+
59
+ Options:
60
+ --config=<file>, -c Load configuration from a YAML or JSON file
61
+ --env-file=<path> Load a .env file for \${VAR} interpolation
62
+ --unsafe Persist tokens to disk
63
+ --help, -h Show this help message
64
+ --version, -v Show version number
65
+
66
+ Shutting down:
67
+ Every proxy runs in THIS process. Closing the session — or SIGINT, or SIGTERM
68
+ — stops all of them and frees their ports. A proxy nobody has used for 30
69
+ minutes stops itself; that is a backstop, not a substitute for proxy_stop.
70
+
71
+ Registering it with a client:
72
+ {
73
+ "mcpServers": {
74
+ "abap-proxy": { "command": "mcp-abap-adt-proxy-mcp" }
75
+ }
76
+ }
77
+
78
+ For more information, see: https://github.com/fr0ster/mcp-abap-adt-proxy
79
+ `);
80
+ }
81
+
82
+ function main() {
83
+ const args = parseArgs();
84
+
85
+ if (args.help) {
86
+ showHelp();
87
+ process.exit(0);
88
+ }
89
+
90
+ if (args.version) {
91
+ console.log(require('../package.json').version);
92
+ process.exit(0);
93
+ }
94
+
95
+ const serverPath = path.resolve(__dirname, '../dist/mcp/cli.js');
96
+
97
+ if (!fs.existsSync(serverPath)) {
98
+ process.stderr.write(`[MCP Proxy] ✗ Server not found at: ${serverPath}\n`);
99
+ process.stderr.write(
100
+ `[MCP Proxy] Make sure to build the project with 'npm run build' first.\n`,
101
+ );
102
+ process.exit(1);
103
+ return;
104
+ }
105
+
106
+ // Loaded in THIS process, never spawned — the same rule the proxy launcher
107
+ // follows, and it matters more here: the proxies the tools start are
108
+ // listeners in this process, and a spawned child would be orphaned by any
109
+ // signal this launcher did not forward, still holding their ports.
110
+ require(serverPath);
111
+ }
112
+
113
+ main();
package/dist/index.d.ts CHANGED
@@ -17,6 +17,16 @@ export declare class McpAbapAdtProxyServer {
17
17
  private config;
18
18
  private httpServer?;
19
19
  private btpProxy?;
20
+ private builtProxyHandler?;
21
+ /**
22
+ * The request path, shared with the MCP mode. Here an authentication failure
23
+ * is fatal: the process exits so whatever started it can start it again with
24
+ * a credential that works.
25
+ *
26
+ * Built on first use rather than as a field initializer, because `config` is
27
+ * assigned in the constructor and a field initializer runs before that.
28
+ */
29
+ private get proxyHandler();
20
30
  private authExiting;
21
31
  constructor(transportConfig?: TransportConfig, configPath?: string);
22
32
  /**
@@ -31,10 +41,6 @@ export declare class McpAbapAdtProxyServer {
31
41
  * Start HTTP server with request interception
32
42
  */
33
43
  private startHttpServer;
34
- /**
35
- * Handle proxy request - get JWT token and forward transparently via reverse proxy
36
- */
37
- private handleProxyRequest;
38
44
  /**
39
45
  * Start SSE server
40
46
  */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAwBH,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,0BAA0B,CAAC;AAgElC;;GAEG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,MAAM,CAAgC;IAC9C,OAAO,CAAC,UAAU,CAAC,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,CAAW;IAC5B,OAAO,CAAC,WAAW,CAAS;gBAEhB,eAAe,CAAC,EAAE,eAAe,EAAE,UAAU,CAAC,EAAE,MAAM;IAoDlE;;OAEG;IACH,OAAO,CAAC,aAAa;IAMrB;;OAEG;IACG,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA2E1B;;OAEG;YACW,eAAe;IAgG7B;;OAEG;YACW,kBAAkB;IAgEhC;;OAEG;YACW,cAAc;IA6T5B;;;;;OAKG;YACW,gBAAgB;IAiC9B;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAoBhC;AAGD,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuBH,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,0BAA0B,CAAC;AAgElC;;GAEG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,MAAM,CAAgC;IAC9C,OAAO,CAAC,UAAU,CAAC,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,CAAW;IAE5B,OAAO,CAAC,iBAAiB,CAAC,CAA+C;IAEzE;;;;;;;OAOG;IACH,OAAO,KAAK,YAAY,GAevB;IACD,OAAO,CAAC,WAAW,CAAS;gBAEhB,eAAe,CAAC,EAAE,eAAe,EAAE,UAAU,CAAC,EAAE,MAAM;IAoDlE;;OAEG;IACH,OAAO,CAAC,aAAa;IAMrB;;OAEG;IACG,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA2E1B;;OAEG;YACW,eAAe;IAiE7B;;OAEG;YACW,cAAc;IAiV5B;;;;;OAKG;YACW,gBAAgB;IAiC9B;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAoBhC;AAGD,eAAe,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -11,10 +11,11 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.McpAbapAdtProxyServer = void 0;
13
13
  const node_http_1 = require("node:http");
14
- const interfaces_1 = require("@mcp-abap-adt/interfaces");
14
+ const interfaces_network_1 = require("@mcp-abap-adt/interfaces-network");
15
15
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
16
16
  const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
17
17
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
18
+ const requestHandler_js_1 = require("./proxy/requestHandler.js");
18
19
  const reverseProxy_js_1 = require("./proxy/reverseProxy.js");
19
20
  const config_js_1 = require("./lib/config.js");
20
21
  const logger_js_1 = require("./lib/logger.js");
@@ -73,6 +74,30 @@ class McpAbapAdtProxyServer {
73
74
  config;
74
75
  httpServer;
75
76
  btpProxy;
77
+ builtProxyHandler;
78
+ /**
79
+ * The request path, shared with the MCP mode. Here an authentication failure
80
+ * is fatal: the process exits so whatever started it can start it again with
81
+ * a credential that works.
82
+ *
83
+ * Built on first use rather than as a field initializer, because `config` is
84
+ * assigned in the constructor and a field initializer runs before that.
85
+ */
86
+ get proxyHandler() {
87
+ if (!this.builtProxyHandler) {
88
+ this.builtProxyHandler = (0, requestHandler_js_1.createProxyRequestHandler)({
89
+ config: this.config,
90
+ proxy: async () => {
91
+ if (!this.btpProxy) {
92
+ this.btpProxy = await (0, btpProxy_js_1.createBtpProxy)(this.config);
93
+ }
94
+ return this.btpProxy;
95
+ },
96
+ onAuthFailure: (error, destination) => this.fatalAuthFailure(error, destination, 'request'),
97
+ });
98
+ }
99
+ return this.builtProxyHandler;
100
+ }
76
101
  authExiting = false;
77
102
  constructor(transportConfig, configPath) {
78
103
  // Load config first to extract transport overrides from YAML/JSON file
@@ -203,39 +228,13 @@ Authorization source: BTP destination "${this.config.btpDestination}"`;
203
228
  headers: sanitizedHeaders,
204
229
  remoteAddress: req.socket.remoteAddress,
205
230
  });
206
- // Intercept and analyze request (headers only, body is piped through)
207
- const configOverrides = {
208
- btpDestination: this.config.btpDestination,
209
- targetUrl: this.config.targetUrl,
210
- };
211
- const intercepted = (0, requestInterceptor_js_1.interceptRequest)(req, undefined, configOverrides, {
212
- skipHeaderValidation: true,
213
- });
214
- // Check routing decision
215
- if (intercepted.routingDecision.strategy === headerAnalyzer_js_1.RoutingStrategy.UNKNOWN) {
216
- logger_js_1.logger?.error('Routing decision failed', {
217
- type: 'ROUTING_DECISION_FAILED',
218
- reason: intercepted.routingDecision.reason,
219
- });
220
- res.writeHead(400, { 'Content-Type': 'application/json' });
221
- res.end(JSON.stringify({
222
- error: intercepted.routingDecision.reason,
223
- }));
224
- return;
225
- }
226
- // Forward request via reverse proxy
227
- logger_js_1.logger?.info('=== PROXYING REQUEST ===', {
228
- type: 'PROXY_REQUEST_START',
229
- btpDestination: intercepted.routingDecision.btpDestination,
230
- });
231
231
  try {
232
- await this.handleProxyRequest(intercepted, req, res);
232
+ await this.proxyHandler(req, res);
233
233
  }
234
234
  catch (error) {
235
235
  logger_js_1.logger?.error('Failed to process request', {
236
236
  type: 'REQUEST_PROCESS_ERROR',
237
237
  error: error instanceof Error ? error.message : String(error),
238
- strategy: intercepted.routingDecision.strategy,
239
238
  });
240
239
  if (!res.headersSent) {
241
240
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -270,62 +269,6 @@ Authorization source: ${authSourceObj}`;
270
269
  });
271
270
  });
272
271
  }
273
- /**
274
- * Handle proxy request - get JWT token and forward transparently via reverse proxy
275
- */
276
- async handleProxyRequest(intercepted, req, res) {
277
- const destination = intercepted.routingDecision.btpDestination;
278
- if (!destination) {
279
- res.writeHead(400, { 'Content-Type': 'application/json' });
280
- res.end(JSON.stringify({ error: 'No BTP destination specified' }));
281
- return;
282
- }
283
- // Ensure proxy is initialized
284
- if (!this.btpProxy) {
285
- this.btpProxy = await (0, btpProxy_js_1.createBtpProxy)(this.config);
286
- }
287
- // Authentication is separated from forwarding: an auth failure is fatal
288
- // (the proxy exits so it can be restarted), while a downstream/forward error
289
- // is returned to the client without killing the proxy.
290
- let jwtToken;
291
- try {
292
- // Get JWT token (cached, auto-refresh)
293
- jwtToken = await this.btpProxy.getJwtToken(destination);
294
- }
295
- catch (authError) {
296
- logger_js_1.logger?.error('Proxy request failed: authentication error', {
297
- type: 'PROXY_REQUEST_AUTH_ERROR',
298
- destination,
299
- error: authError instanceof Error ? authError.message : String(authError),
300
- });
301
- if (!res.headersSent) {
302
- res.writeHead(502, { 'Content-Type': 'application/json' });
303
- res.end(JSON.stringify({ error: 'Authentication failed' }));
304
- }
305
- await this.fatalAuthFailure(authError, destination, 'request');
306
- return;
307
- }
308
- try {
309
- // Get target URL
310
- const targetUrl = intercepted.routingDecision.targetUrl ||
311
- (await this.btpProxy.getTargetUrl(destination));
312
- // Forward request transparently (inject default headers from config)
313
- await (0, reverseProxy_js_1.forwardRequest)(req, res, targetUrl, jwtToken, this.config.defaultHeaders);
314
- }
315
- catch (error) {
316
- logger_js_1.logger?.error('Proxy request failed', {
317
- type: 'PROXY_REQUEST_ERROR',
318
- destination,
319
- error: error instanceof Error ? error.message : String(error),
320
- });
321
- if (!res.headersSent) {
322
- res.writeHead(502, { 'Content-Type': 'application/json' });
323
- res.end(JSON.stringify({
324
- error: error instanceof Error ? error.message : 'Proxy error',
325
- }));
326
- }
327
- }
328
- }
329
272
  /**
330
273
  * Start SSE server
331
274
  */
@@ -371,8 +314,8 @@ Authorization source: ${authSourceObj}`;
371
314
  }
372
315
  }
373
316
  // Add config overrides if not present in headers
374
- if (this.config.btpDestination && !headers[interfaces_1.HEADER_BTP_DESTINATION]) {
375
- headers[interfaces_1.HEADER_BTP_DESTINATION] = this.config.btpDestination;
317
+ if (this.config.btpDestination && !headers[interfaces_network_1.HEADER_BTP_DESTINATION]) {
318
+ headers[interfaces_network_1.HEADER_BTP_DESTINATION] = this.config.btpDestination;
376
319
  }
377
320
  logger_js_1.logger?.debug('SSE request received', {
378
321
  type: 'SSE_HTTP_REQUEST',
@@ -483,14 +426,15 @@ Authorization source: ${authSourceObj}`;
483
426
  }
484
427
  // Read request body
485
428
  let body;
429
+ let rawBody;
486
430
  try {
487
431
  const chunks = [];
488
432
  for await (const chunk of req) {
489
433
  chunks.push(chunk);
490
434
  }
491
435
  if (chunks.length > 0) {
492
- const bodyString = Buffer.concat(chunks).toString('utf-8');
493
- body = JSON.parse(bodyString);
436
+ rawBody = Buffer.concat(chunks);
437
+ body = JSON.parse(rawBody.toString('utf-8'));
494
438
  }
495
439
  }
496
440
  catch (error) {
@@ -532,19 +476,32 @@ Authorization source: ${authSourceObj}`;
532
476
  }));
533
477
  return;
534
478
  }
535
- // Handle proxy request via BtpProxy (JSON-RPC mode for SSE)
479
+ // Forward through the same transparent pipe the other path uses.
480
+ //
481
+ // This used to rebuild the request by hand and carry it over axios,
482
+ // which buffered the response and rewrapped it as a JSON-RPC envelope.
483
+ // The body above was already read — the JSON-RPC `id` in it is what the
484
+ // error envelopes below have to echo — so it is handed over rather than
485
+ // piped. The response still streams, which is the direction that
486
+ // carries an event stream.
536
487
  try {
537
488
  if (!this.btpProxy) {
538
489
  this.btpProxy = await (0, btpProxy_js_1.createBtpProxy)(this.config);
539
490
  }
540
- const proxyResponse = await this.btpProxy.proxyRequest({
541
- method: intercepted.method,
542
- url: intercepted.url,
543
- data: body,
544
- id: getBodyId(body),
545
- }, intercepted.routingDecision, intercepted.headers);
546
- res.writeHead(200, { 'Content-Type': 'application/json' });
547
- res.end(JSON.stringify(proxyResponse));
491
+ const destination = intercepted.routingDecision.btpDestination ??
492
+ this.config.btpDestination;
493
+ if (!destination) {
494
+ throw new Error('No BTP destination for this request');
495
+ }
496
+ const authorization = await this.btpProxy.getAuthorizationHeader(destination);
497
+ const targetUrl = intercepted.routingDecision.targetUrl ||
498
+ (await this.btpProxy.getTargetUrl(destination));
499
+ await (0, reverseProxy_js_1.forwardRequest)(req, res, targetUrl, authorization, this.config.defaultHeaders,
500
+ // The bytes as they arrived, not the parse re-serialised: the
501
+ // client's `content-length` is forwarded unchanged, and a
502
+ // re-serialised body differs from it by whatever whitespace the
503
+ // sender used.
504
+ rawBody);
548
505
  }
549
506
  catch (error) {
550
507
  logger_js_1.logger?.error('Failed to process SSE POST request', {
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IAEjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGxC,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAG/B,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,WAAW,CAoC3D;AAwED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,mBAAmB,CAQrB;AAkCD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAYhC;AAmJD;;GAEG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,SAAS,CAElD;AAUD;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG;IACnD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAoDA"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IAEjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGxC,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAG/B,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,WAAW,CA8C3D;AAwED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,mBAAmB,CAQrB;AAkCD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAYhC;AAmJD;;GAEG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,SAAS,CAElD;AAUD;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG;IACnD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAoDA"}
@@ -68,7 +68,15 @@ function loadConfig(configPath) {
68
68
  const envFilePath = resolveEnvFilePath(fileConfig, finalConfigPath);
69
69
  const envFileMap = envFilePath ? (0, envInterpolation_js_1.loadEnvFile)(envFilePath) : {};
70
70
  const lookup = (0, envInterpolation_js_1.buildLookup)(envFileMap);
71
- const interpolated = (0, envInterpolation_js_1.interpolateConfig)(fileConfig, lookup);
71
+ const keys = Object.keys(envFileMap);
72
+ const sources = !envFilePath
73
+ ? 'process.env only — no env file was given (--env-file or envFile:)'
74
+ : keys.length === 0
75
+ ? `process.env, then ${envFilePath}, which yielded NO variables ` +
76
+ `(${fs.statSync(envFilePath).size} bytes) — check its encoding, ` +
77
+ `since a UTF-16 file reads as nothing here, and that lines look like KEY=value`
78
+ : `process.env, then ${envFilePath} (${keys.length}: ${keys.join(', ')})`;
79
+ const interpolated = (0, envInterpolation_js_1.interpolateConfig)(fileConfig, lookup, '', sources);
72
80
  delete interpolated.envFile;
73
81
  const base = applyDefaults(interpolated);
74
82
  const cli = readCliOverrides();
@@ -8,14 +8,16 @@
8
8
  * default when one is given. A ${VAR} without a default that resolves to
9
9
  * undefined throws, naming the variable and the field it came from.
10
10
  */
11
- export declare function interpolateString(input: string, lookup: (key: string) => string | undefined, fieldPath: string): string;
11
+ export declare function interpolateString(input: string, lookup: (key: string) => string | undefined, fieldPath: string,
12
+ /** Where values were looked for, named in the failure. */
13
+ sources?: string): string;
12
14
  /**
13
15
  * Recursively interpolate all string values in a parsed config object.
14
16
  * Objects/arrays are walked; non-string scalars are returned unchanged.
15
17
  * The field path (e.g. `defaultHeaders.x-sap-password`) is threaded through for
16
18
  * error messages.
17
19
  */
18
- export declare function interpolateConfig(value: unknown, lookup: (key: string) => string | undefined, path?: string): unknown;
20
+ export declare function interpolateConfig(value: unknown, lookup: (key: string) => string | undefined, path?: string, sources?: string): unknown;
19
21
  /**
20
22
  * Parse a .env file into a flat map. Throws if the path is given but missing —
21
23
  * a specified-yet-absent secret source is a configuration error, not a no-op.
@@ -23,6 +25,16 @@ export declare function interpolateConfig(value: unknown, lookup: (key: string)
23
25
  export declare function loadEnvFile(envFilePath: string): Record<string, string>;
24
26
  /**
25
27
  * Build a lookup over process.env (highest priority) then the parsed .env map.
28
+ *
29
+ * An EMPTY environment variable does not count as a value. It used to: `??`
30
+ * skips only `undefined`, so a `SAP_LOGIN=` sitting in the environment won
31
+ * against the env file the user had explicitly pointed at with `--env-file`,
32
+ * and the header went out empty with nothing reported. Windows carries
33
+ * variables nobody set deliberately, which is why the same config and the same
34
+ * `.env` behaved differently there.
35
+ *
36
+ * A real value in the environment still wins — that is the documented
37
+ * precedence, and it is how a one-off override is meant to work.
26
38
  */
27
39
  export declare function buildLookup(envFileMap: Record<string, string>): (key: string) => string | undefined;
28
40
  //# sourceMappingURL=envInterpolation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"envInterpolation.d.ts","sourceRoot":"","sources":["../../src/lib/envInterpolation.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,SAAS,EAAE,MAAM,GAChB,MAAM,CAiBR;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,IAAI,SAAK,GACR,OAAO,CAiBT;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAKvE;AAED;;GAEG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAGrC"}
1
+ {"version":3,"file":"envInterpolation.d.ts","sourceRoot":"","sources":["../../src/lib/envInterpolation.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,SAAS,EAAE,MAAM;AACjB,0DAA0D;AAC1D,OAAO,SAAgB,GACtB,MAAM,CAsBR;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,IAAI,SAAK,EACT,OAAO,SAAgB,GACtB,OAAO,CAsBT;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASvE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAQrC"}
@@ -50,7 +50,9 @@ const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
50
50
  * default when one is given. A ${VAR} without a default that resolves to
51
51
  * undefined throws, naming the variable and the field it came from.
52
52
  */
53
- function interpolateString(input, lookup, fieldPath) {
53
+ function interpolateString(input, lookup, fieldPath,
54
+ /** Where values were looked for, named in the failure. */
55
+ sources = 'process.env') {
54
56
  return input.replace(PLACEHOLDER, (_match, name, defaultVal) => {
55
57
  const value = lookup(name);
56
58
  const isEmpty = value === undefined || value === '';
@@ -58,7 +60,12 @@ function interpolateString(input, lookup, fieldPath) {
58
60
  return isEmpty ? defaultVal : value;
59
61
  }
60
62
  if (value === undefined) {
61
- throw new Error(`Config references undefined env variable: ${name} (referenced in ${fieldPath})`);
63
+ // Naming where it looked, because the old message named only the
64
+ // variable — so a file never read, a file read empty, and a value
65
+ // shadowed by the environment all produced the same sentence and the
66
+ // cause had to be guessed.
67
+ throw new Error(`Config references undefined env variable: ${name} ` +
68
+ `(referenced in ${fieldPath}; looked in ${sources})`);
62
69
  }
63
70
  return value;
64
71
  });
@@ -69,17 +76,17 @@ function interpolateString(input, lookup, fieldPath) {
69
76
  * The field path (e.g. `defaultHeaders.x-sap-password`) is threaded through for
70
77
  * error messages.
71
78
  */
72
- function interpolateConfig(value, lookup, path = '') {
79
+ function interpolateConfig(value, lookup, path = '', sources = 'process.env') {
73
80
  if (typeof value === 'string') {
74
- return interpolateString(value, lookup, path || '(root)');
81
+ return interpolateString(value, lookup, path || '(root)', sources);
75
82
  }
76
83
  if (Array.isArray(value)) {
77
- return value.map((item, i) => interpolateConfig(item, lookup, `${path}[${i}]`));
84
+ return value.map((item, i) => interpolateConfig(item, lookup, `${path}[${i}]`, sources));
78
85
  }
79
86
  if (value !== null && typeof value === 'object') {
80
87
  const out = {};
81
88
  for (const [key, val] of Object.entries(value)) {
82
- out[key] = interpolateConfig(val, lookup, path ? `${path}.${key}` : key);
89
+ out[key] = interpolateConfig(val, lookup, path ? `${path}.${key}` : key, sources);
83
90
  }
84
91
  return out;
85
92
  }
@@ -93,11 +100,31 @@ function loadEnvFile(envFilePath) {
93
100
  if (!fs.existsSync(envFilePath)) {
94
101
  throw new Error(`env file not found: ${envFilePath}`);
95
102
  }
103
+ // Not an error when it yields nothing: an empty `.env` is legitimate, and a
104
+ // config may take every value from the environment. But the emptiness is
105
+ // reported by whoever needs a variable — see the `sources` note threaded into
106
+ // the interpolation failure, which names the file, its size and its key count.
96
107
  return dotenv.parse(fs.readFileSync(envFilePath, 'utf-8'));
97
108
  }
98
109
  /**
99
110
  * Build a lookup over process.env (highest priority) then the parsed .env map.
111
+ *
112
+ * An EMPTY environment variable does not count as a value. It used to: `??`
113
+ * skips only `undefined`, so a `SAP_LOGIN=` sitting in the environment won
114
+ * against the env file the user had explicitly pointed at with `--env-file`,
115
+ * and the header went out empty with nothing reported. Windows carries
116
+ * variables nobody set deliberately, which is why the same config and the same
117
+ * `.env` behaved differently there.
118
+ *
119
+ * A real value in the environment still wins — that is the documented
120
+ * precedence, and it is how a one-off override is meant to work.
100
121
  */
101
122
  function buildLookup(envFileMap) {
102
- return (key) => process.env[key] ?? envFileMap[key];
123
+ return (key) => {
124
+ const fromEnvironment = process.env[key];
125
+ if (fromEnvironment !== undefined && fromEnvironment !== '') {
126
+ return fromEnvironment;
127
+ }
128
+ return envFileMap[key];
129
+ };
103
130
  }