@mcp-abap-adt/proxy 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +669 -17
  3. package/README.md +79 -8
  4. package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
  5. package/dist/index.d.ts +10 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +54 -97
  8. package/dist/lib/stores.d.ts +23 -2
  9. package/dist/lib/stores.d.ts.map +1 -1
  10. package/dist/lib/stores.js +56 -1
  11. package/dist/mcp/cli.d.ts +2 -0
  12. package/dist/mcp/cli.d.ts.map +1 -0
  13. package/dist/mcp/cli.js +21 -0
  14. package/dist/mcp/configs.d.ts +33 -0
  15. package/dist/mcp/configs.d.ts.map +1 -0
  16. package/dist/mcp/configs.js +142 -0
  17. package/dist/mcp/ports.d.ts +15 -0
  18. package/dist/mcp/ports.d.ts.map +1 -0
  19. package/dist/mcp/ports.js +35 -0
  20. package/dist/mcp/registry.d.ts +57 -0
  21. package/dist/mcp/registry.d.ts.map +1 -0
  22. package/dist/mcp/registry.js +145 -0
  23. package/dist/mcp/server.d.ts +34 -0
  24. package/dist/mcp/server.d.ts.map +1 -0
  25. package/dist/mcp/server.js +79 -0
  26. package/dist/mcp/shutdown.d.ts +37 -0
  27. package/dist/mcp/shutdown.d.ts.map +1 -0
  28. package/dist/mcp/shutdown.js +82 -0
  29. package/dist/mcp/supervisor.d.ts +125 -0
  30. package/dist/mcp/supervisor.d.ts.map +1 -0
  31. package/dist/mcp/supervisor.js +330 -0
  32. package/dist/mcp/tools.d.ts +28 -0
  33. package/dist/mcp/tools.d.ts.map +1 -0
  34. package/dist/mcp/tools.js +152 -0
  35. package/dist/proxy/btpProxy.d.ts +24 -73
  36. package/dist/proxy/btpProxy.d.ts.map +1 -1
  37. package/dist/proxy/btpProxy.js +65 -616
  38. package/dist/proxy/credentials.d.ts +45 -0
  39. package/dist/proxy/credentials.d.ts.map +1 -0
  40. package/dist/proxy/credentials.js +41 -0
  41. package/dist/proxy/requestHandler.d.ts +38 -0
  42. package/dist/proxy/requestHandler.d.ts.map +1 -0
  43. package/dist/proxy/requestHandler.js +73 -0
  44. package/dist/proxy/reverseProxy.d.ts +11 -2
  45. package/dist/proxy/reverseProxy.d.ts.map +1 -1
  46. package/dist/proxy/reverseProxy.js +52 -9
  47. package/dist/router/headerAnalyzer.js +2 -2
  48. package/dist/router/requestInterceptor.js +9 -9
  49. package/docs/API.md +172 -0
  50. package/docs/ARCHITECTURE.md +322 -0
  51. package/docs/CLIENT_SETUP.md +413 -0
  52. package/docs/CONFIGURATION.md +258 -0
  53. package/docs/MIGRATION-4.0.md +125 -0
  54. package/docs/ROUTING_LOGIC.md +126 -0
  55. package/docs/TROUBLESHOOTING.md +488 -0
  56. package/docs/USAGE.md +422 -0
  57. package/docs/YAML_CONFIG.md +273 -0
  58. package/docs/mcp-proxy-config.example.yaml +62 -0
  59. package/package.json +17 -10
  60. package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
  61. package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
  62. package/dist/proxy/cloudLlmHubProxy.js +0 -3
@@ -0,0 +1,45 @@
1
+ import { TokenAuthProvider } from '@mcp-abap-adt/connection';
2
+ import type { ITokenRefresher } from '@mcp-abap-adt/interfaces-auth';
3
+ /**
4
+ * What a destination authenticates with, and where its requests go.
5
+ *
6
+ * The credential is the ecosystem's shared one — `TokenAuthProvider` over the
7
+ * broker's `ITokenRefresher`. It is asked for a header per request and renews
8
+ * behind that call, which is why nothing here caches a token, decodes a JWT
9
+ * `exp`, or runs a refresh timer. All three used to live in `BtpProxy` and all
10
+ * three duplicated the broker, the last one by holding a `setTimeout` per
11
+ * destination for the life of the process.
12
+ */
13
+ export interface DestinationAccess {
14
+ readonly credential: TokenAuthProvider;
15
+ /** From the service key. `undefined` when it carries none. */
16
+ readonly baseUrl: string | undefined;
17
+ }
18
+ /**
19
+ * The part of `AuthBroker` this needs, named rather than taken whole.
20
+ *
21
+ * Two methods is the entire seam, so a test states two methods instead of
22
+ * standing up a broker with a service-key store behind it.
23
+ */
24
+ export interface CredentialSource {
25
+ createTokenRefresher(destination: string): ITokenRefresher;
26
+ getConnectionConfig(destination: string): Promise<{
27
+ serviceUrl?: string;
28
+ } | null>;
29
+ }
30
+ export type BrokerFor = (destination: string) => Promise<CredentialSource>;
31
+ export declare class DestinationCredentials {
32
+ private readonly brokerFor;
33
+ /**
34
+ * The promise is held, not the result, so concurrent first requests for one
35
+ * destination share a single build instead of racing to make two credentials
36
+ * and two brokers.
37
+ */
38
+ private readonly held;
39
+ constructor(brokerFor: BrokerFor);
40
+ get(destination: string): Promise<DestinationAccess>;
41
+ private build;
42
+ /** Drop everything held. Nothing here outlives a stop. */
43
+ clear(): void;
44
+ }
45
+ //# sourceMappingURL=credentials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../../src/proxy/credentials.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAErE;;;;;;;;;GASG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,UAAU,EAAE,iBAAiB,CAAC;IACvC,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;CACtC;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,eAAe,CAAC;IAC3D,mBAAmB,CACjB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC;QAAE,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CAC5C;AAED,MAAM,MAAM,SAAS,GAAG,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAE3E,qBAAa,sBAAsB;IAQrB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAPtC;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAiD;gBAEzC,SAAS,EAAE,SAAS;IAEjD,GAAG,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;YActC,KAAK;IASnB,0DAA0D;IAC1D,KAAK,IAAI,IAAI;CAGd"}
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DestinationCredentials = void 0;
4
+ // src/proxy/credentials.ts
5
+ const connection_1 = require("@mcp-abap-adt/connection");
6
+ class DestinationCredentials {
7
+ brokerFor;
8
+ /**
9
+ * The promise is held, not the result, so concurrent first requests for one
10
+ * destination share a single build instead of racing to make two credentials
11
+ * and two brokers.
12
+ */
13
+ held = new Map();
14
+ constructor(brokerFor) {
15
+ this.brokerFor = brokerFor;
16
+ }
17
+ get(destination) {
18
+ const existing = this.held.get(destination);
19
+ if (existing)
20
+ return existing;
21
+ const pending = this.build(destination).catch((error) => {
22
+ // A rejected promise left in the map would serve the failure forever: a
23
+ // service key added after the first miss would never be seen.
24
+ this.held.delete(destination);
25
+ throw error;
26
+ });
27
+ this.held.set(destination, pending);
28
+ return pending;
29
+ }
30
+ async build(destination) {
31
+ const broker = await this.brokerFor(destination);
32
+ const credential = new connection_1.TokenAuthProvider(broker.createTokenRefresher(destination));
33
+ const connection = await broker.getConnectionConfig(destination);
34
+ return { credential, baseUrl: connection?.serviceUrl };
35
+ }
36
+ /** Drop everything held. Nothing here outlives a stop. */
37
+ clear() {
38
+ this.held.clear();
39
+ }
40
+ }
41
+ exports.DestinationCredentials = DestinationCredentials;
@@ -0,0 +1,38 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /** The part of `BtpProxy` a forwarded request needs. */
3
+ export interface CredentialFacade {
4
+ getAuthorizationHeader(destination: string): Promise<string | null>;
5
+ getTargetUrl(destination: string): Promise<string>;
6
+ }
7
+ export interface ProxyRequestHandlerOptions {
8
+ config: {
9
+ btpDestination?: string;
10
+ targetUrl?: string;
11
+ defaultHeaders?: Record<string, string>;
12
+ };
13
+ /** Asked per request; the caller decides whether to build or reuse. */
14
+ proxy: () => Promise<CredentialFacade>;
15
+ /**
16
+ * What an AUTHENTICATION failure means here — and it is a parameter because
17
+ * the two callers disagree.
18
+ *
19
+ * The standalone proxy treats it as fatal and exits, so whatever started it
20
+ * can start it again with a credential that works. The MCP mode must not:
21
+ * exiting there takes the MCP server down with it, and the client's session
22
+ * with that. Left out, the failure is simply answered and the process lives.
23
+ *
24
+ * A failure to FORWARD never reaches this. A dead backend is not a credential
25
+ * problem, and treating it as one is how a proxy ends up exiting over
26
+ * someone else's outage.
27
+ */
28
+ onAuthFailure?: (error: unknown, destination: string) => Promise<void> | void;
29
+ }
30
+ /**
31
+ * The request path, in one place: analyse, authenticate, forward.
32
+ *
33
+ * It was a closure inside the standalone server, which is where it could stay
34
+ * while there was one caller. The MCP mode is the second, and a copy of this
35
+ * flow over there would be two places for the routing rules to drift apart.
36
+ */
37
+ export declare function createProxyRequestHandler(options: ProxyRequestHandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
38
+ //# sourceMappingURL=requestHandler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requestHandler.d.ts","sourceRoot":"","sources":["../../src/proxy/requestHandler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAMjE,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAC/B,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACpE,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE;QACN,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACzC,CAAC;IACF,uEAAuE;IACvE,KAAK,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACvC;;;;;;;;;;;;OAYG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC/E;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CA8E9D"}
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createProxyRequestHandler = createProxyRequestHandler;
4
+ const logger_js_1 = require("../lib/logger.js");
5
+ const headerAnalyzer_js_1 = require("../router/headerAnalyzer.js");
6
+ const requestInterceptor_js_1 = require("../router/requestInterceptor.js");
7
+ const reverseProxy_js_1 = require("./reverseProxy.js");
8
+ /**
9
+ * The request path, in one place: analyse, authenticate, forward.
10
+ *
11
+ * It was a closure inside the standalone server, which is where it could stay
12
+ * while there was one caller. The MCP mode is the second, and a copy of this
13
+ * flow over there would be two places for the routing rules to drift apart.
14
+ */
15
+ function createProxyRequestHandler(options) {
16
+ const { config, proxy, onAuthFailure } = options;
17
+ return async function handle(req, res) {
18
+ const intercepted = (0, requestInterceptor_js_1.interceptRequest)(req, undefined, { btpDestination: config.btpDestination, targetUrl: config.targetUrl }, { skipHeaderValidation: true });
19
+ if (intercepted.routingDecision.strategy === headerAnalyzer_js_1.RoutingStrategy.UNKNOWN) {
20
+ logger_js_1.logger?.error('Routing decision failed', {
21
+ type: 'ROUTING_DECISION_FAILED',
22
+ reason: intercepted.routingDecision.reason,
23
+ });
24
+ res.writeHead(400, { 'Content-Type': 'application/json' });
25
+ res.end(JSON.stringify({ error: intercepted.routingDecision.reason }));
26
+ return;
27
+ }
28
+ const destination = intercepted.routingDecision.btpDestination;
29
+ if (!destination) {
30
+ res.writeHead(400, { 'Content-Type': 'application/json' });
31
+ res.end(JSON.stringify({ error: 'No BTP destination specified' }));
32
+ return;
33
+ }
34
+ const facade = await proxy();
35
+ // Authentication is separated from forwarding on purpose: the two failures
36
+ // mean different things, and only one of them is about us.
37
+ let authorization;
38
+ try {
39
+ authorization = await facade.getAuthorizationHeader(destination);
40
+ }
41
+ catch (authError) {
42
+ logger_js_1.logger?.error('Proxy request failed: authentication error', {
43
+ type: 'PROXY_REQUEST_AUTH_ERROR',
44
+ destination,
45
+ error: authError instanceof Error ? authError.message : String(authError),
46
+ });
47
+ if (!res.headersSent) {
48
+ res.writeHead(502, { 'Content-Type': 'application/json' });
49
+ res.end(JSON.stringify({ error: 'Authentication failed' }));
50
+ }
51
+ await onAuthFailure?.(authError, destination);
52
+ return;
53
+ }
54
+ try {
55
+ const targetUrl = intercepted.routingDecision.targetUrl ||
56
+ (await facade.getTargetUrl(destination));
57
+ await (0, reverseProxy_js_1.forwardRequest)(req, res, targetUrl, authorization, config.defaultHeaders);
58
+ }
59
+ catch (error) {
60
+ logger_js_1.logger?.error('Proxy request failed', {
61
+ type: 'PROXY_REQUEST_ERROR',
62
+ destination,
63
+ error: error instanceof Error ? error.message : String(error),
64
+ });
65
+ if (!res.headersSent) {
66
+ res.writeHead(502, { 'Content-Type': 'application/json' });
67
+ res.end(JSON.stringify({
68
+ error: error instanceof Error ? error.message : 'Proxy error',
69
+ }));
70
+ }
71
+ }
72
+ };
73
+ }
@@ -1,7 +1,16 @@
1
1
  import * as http from 'node:http';
2
2
  /**
3
- * Forward an HTTP request to a target URL with JWT injection.
3
+ * Forward an HTTP request to a target URL, with the credential's header.
4
4
  * Streams both request and response using pipe().
5
+ *
6
+ * `authorization` is a complete header VALUE, not a token: it is what
7
+ * `IAuthProvider.authorizationHeader()` answers, `Bearer <token>` and all.
8
+ * Composing `Bearer` here as well would send `Bearer Bearer <token>`.
9
+ *
10
+ * `requestBody` is for a caller that has already read the request: the SSE path
11
+ * parses the JSON-RPC body because an error envelope has to echo its `id`, and
12
+ * a stream read once cannot be piped. The RESPONSE still streams either way,
13
+ * which is the direction that carries an event stream.
5
14
  */
6
- export declare function forwardRequest(clientReq: http.IncomingMessage, clientRes: http.ServerResponse, targetBaseUrl: string, jwtToken: string, defaultHeaders?: Record<string, string>): Promise<void>;
15
+ export declare function forwardRequest(clientReq: http.IncomingMessage, clientRes: http.ServerResponse, targetBaseUrl: string, authorization: string | null, defaultHeaders?: Record<string, string>, requestBody?: Buffer): Promise<void>;
7
16
  //# sourceMappingURL=reverseProxy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reverseProxy.d.ts","sourceRoot":"","sources":["../../src/proxy/reverseProxy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAoBlC;;;GAGG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,IAAI,CAAC,eAAe,EAC/B,SAAS,EAAE,IAAI,CAAC,cAAc,EAC9B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,EAChB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACtC,OAAO,CAAC,IAAI,CAAC,CAiGf"}
1
+ {"version":3,"file":"reverseProxy.d.ts","sourceRoot":"","sources":["../../src/proxy/reverseProxy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAoBlC;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAClC,SAAS,EAAE,IAAI,CAAC,eAAe,EAC/B,SAAS,EAAE,IAAI,CAAC,cAAc,EAC9B,aAAa,EAAE,MAAM,EACrB,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACvC,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAkIf"}
@@ -54,10 +54,19 @@ const HOP_BY_HOP_HEADERS = new Set([
54
54
  'host',
55
55
  ]);
56
56
  /**
57
- * Forward an HTTP request to a target URL with JWT injection.
57
+ * Forward an HTTP request to a target URL, with the credential's header.
58
58
  * Streams both request and response using pipe().
59
+ *
60
+ * `authorization` is a complete header VALUE, not a token: it is what
61
+ * `IAuthProvider.authorizationHeader()` answers, `Bearer <token>` and all.
62
+ * Composing `Bearer` here as well would send `Bearer Bearer <token>`.
63
+ *
64
+ * `requestBody` is for a caller that has already read the request: the SSE path
65
+ * parses the JSON-RPC body because an error envelope has to echo its `id`, and
66
+ * a stream read once cannot be piped. The RESPONSE still streams either way,
67
+ * which is the direction that carries an event stream.
59
68
  */
60
- async function forwardRequest(clientReq, clientRes, targetBaseUrl, jwtToken, defaultHeaders) {
69
+ async function forwardRequest(clientReq, clientRes, targetBaseUrl, authorization, defaultHeaders, requestBody) {
61
70
  const targetUrl = new node_url_1.URL(clientReq.url || '/', targetBaseUrl);
62
71
  // Build forwarded headers: defaults first, then client headers override
63
72
  const forwardedHeaders = {};
@@ -77,9 +86,18 @@ async function forwardRequest(clientReq, clientRes, targetBaseUrl, jwtToken, def
77
86
  forwardedHeaders[key] = value;
78
87
  }
79
88
  }
80
- // Inject JWT
81
- if (jwtToken) {
82
- forwardedHeaders.authorization = `Bearer ${jwtToken}`;
89
+ // The credential's header, verbatim.
90
+ //
91
+ // `null` means this credential is not a header at all — a certificate
92
+ // authenticates through TLS and has none — and must reach the target as an
93
+ // ABSENT header rather than an empty one, which is a different claim.
94
+ //
95
+ // `''` is treated the same way. The contract calls the empty string a legal
96
+ // header value, but the provider that answers it here returns it for "no
97
+ // token", not for "an empty Authorization" — and that is also what this
98
+ // function did before it carried header values.
99
+ if (authorization) {
100
+ forwardedHeaders.authorization = authorization;
83
101
  }
84
102
  // Set correct host for target
85
103
  forwardedHeaders.host = targetUrl.host;
@@ -106,6 +124,13 @@ async function forwardRequest(clientReq, clientRes, targetBaseUrl, jwtToken, def
106
124
  target: targetUrl.toString(),
107
125
  });
108
126
  return new Promise((resolve) => {
127
+ let settled = false;
128
+ const finish = () => {
129
+ if (settled)
130
+ return;
131
+ settled = true;
132
+ resolve();
133
+ };
109
134
  const proxyReq = transport.request(options, (proxyRes) => {
110
135
  // Forward status code
111
136
  const statusCode = proxyRes.statusCode || 502;
@@ -125,7 +150,19 @@ async function forwardRequest(clientReq, clientRes, targetBaseUrl, jwtToken, def
125
150
  });
126
151
  clientRes.writeHead(statusCode, responseHeaders);
127
152
  proxyRes.pipe(clientRes);
128
- proxyRes.on('end', resolve);
153
+ proxyRes.on('end', finish);
154
+ });
155
+ // The client going away has to take the upstream with it.
156
+ //
157
+ // A stop destroys the CLIENT socket — `closeAllConnections()` — and nothing
158
+ // here destroyed the other one, so an abandoned event stream left a live
159
+ // connection to the target: a released port reported while a socket was
160
+ // still held, accumulating across repeated start/stop. It also left this
161
+ // promise pending forever, since `proxyRes` never ends.
162
+ clientRes.on('close', () => {
163
+ if (!settled)
164
+ proxyReq.destroy();
165
+ finish();
129
166
  });
130
167
  proxyReq.on('error', (err) => {
131
168
  logger_js_1.logger?.error('Reverse proxy connection error', {
@@ -137,9 +174,15 @@ async function forwardRequest(clientReq, clientRes, targetBaseUrl, jwtToken, def
137
174
  clientRes.writeHead(502, { 'Content-Type': 'application/json' });
138
175
  clientRes.end(JSON.stringify({ error: `Proxy error: ${err.message}` }));
139
176
  }
140
- resolve();
177
+ finish();
141
178
  });
142
- // Pipe client request body to backend
143
- clientReq.pipe(proxyReq);
179
+ // Pipe client request body to backend — or write what the caller already
180
+ // read off it, since a spent stream pipes nothing.
181
+ if (requestBody === undefined) {
182
+ clientReq.pipe(proxyReq);
183
+ }
184
+ else {
185
+ proxyReq.end(requestBody);
186
+ }
144
187
  });
145
188
  }
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.RoutingStrategy = void 0;
7
7
  exports.analyzeHeaders = analyzeHeaders;
8
8
  exports.shouldProxy = shouldProxy;
9
- const interfaces_1 = require("@mcp-abap-adt/interfaces");
9
+ const interfaces_network_1 = require("@mcp-abap-adt/interfaces-network");
10
10
  const logger_js_1 = require("../lib/logger.js");
11
11
  var RoutingStrategy;
12
12
  (function (RoutingStrategy) {
@@ -50,7 +50,7 @@ function analyzeHeaders(headers, configOverrides) {
50
50
  };
51
51
  // Extract authorization destination for BTP Cloud (x-sap-destination)
52
52
  // Command-line parameter --btp takes precedence over header
53
- const btpDestinationHeader = getHeaderValue(interfaces_1.HEADER_SAP_DESTINATION);
53
+ const btpDestinationHeader = getHeaderValue(interfaces_network_1.HEADER_SAP_DESTINATION);
54
54
  const extractedBtpDestination = configOverrides?.btpDestination
55
55
  ? configOverrides.btpDestination
56
56
  : btpDestinationHeader;
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.interceptRequest = interceptRequest;
7
7
  exports.sanitizeHeadersForLogging = sanitizeHeadersForLogging;
8
8
  const header_validator_1 = require("@mcp-abap-adt/header-validator");
9
- const interfaces_1 = require("@mcp-abap-adt/interfaces");
9
+ const interfaces_network_1 = require("@mcp-abap-adt/interfaces-network");
10
10
  const logger_js_1 = require("../lib/logger.js");
11
11
  const headerAnalyzer_js_1 = require("./headerAnalyzer.js");
12
12
  /**
@@ -38,9 +38,9 @@ function interceptRequest(req, body, configOverrides, options) {
38
38
  }
39
39
  }
40
40
  // Extract session ID if present
41
- const sessionId = (req.headers[interfaces_1.HEADER_SESSION_ID] ||
42
- req.headers[interfaces_1.HEADER_MCP_SESSION_ID] ||
43
- req.headers[interfaces_1.HEADER_X_MCP_SESSION_ID]);
41
+ const sessionId = (req.headers[interfaces_network_1.HEADER_SESSION_ID] ||
42
+ req.headers[interfaces_network_1.HEADER_MCP_SESSION_ID] ||
43
+ req.headers[interfaces_network_1.HEADER_X_MCP_SESSION_ID]);
44
44
  // Generate client ID
45
45
  const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
46
46
  // Log intercepted request
@@ -69,11 +69,11 @@ function interceptRequest(req, body, configOverrides, options) {
69
69
  function sanitizeHeadersForLogging(headers) {
70
70
  const sanitized = {};
71
71
  const sensitiveKeys = [
72
- interfaces_1.HEADER_AUTHORIZATION,
73
- interfaces_1.HEADER_SAP_JWT_TOKEN,
74
- interfaces_1.HEADER_SAP_REFRESH_TOKEN,
75
- interfaces_1.HEADER_SAP_PASSWORD,
76
- interfaces_1.HEADER_SAP_UAA_CLIENT_SECRET,
72
+ interfaces_network_1.HEADER_AUTHORIZATION,
73
+ interfaces_network_1.HEADER_SAP_JWT_TOKEN,
74
+ interfaces_network_1.HEADER_SAP_REFRESH_TOKEN,
75
+ interfaces_network_1.HEADER_SAP_PASSWORD,
76
+ interfaces_network_1.HEADER_SAP_UAA_CLIENT_SECRET,
77
77
  ].map((k) => k.toLowerCase());
78
78
  for (const [key, value] of Object.entries(headers)) {
79
79
  if (sensitiveKeys.includes(key.toLowerCase())) {
package/docs/API.md ADDED
@@ -0,0 +1,172 @@
1
+ # API Documentation
2
+
3
+ This document describes the API and interfaces provided by `@mcp-abap-adt/proxy`.
4
+
5
+ ## Server Class
6
+
7
+ ### `McpAbapAdtProxyServer`
8
+
9
+ Main server class for the MCP ABAP ADT Proxy.
10
+
11
+ #### Constructor
12
+
13
+ ```typescript
14
+ constructor(transportConfig?: TransportConfig, configPath?: string)
15
+ ```
16
+
17
+ **Parameters:**
18
+ - `transportConfig` (optional): Transport configuration. If not provided, parsed from command line arguments and environment variables.
19
+ - `configPath` (optional): Path to configuration file. If not provided, searches default locations.
20
+
21
+ **Example:**
22
+ ```typescript
23
+ import { McpAbapAdtProxyServer } from "@mcp-abap-adt/proxy";
24
+
25
+ const server = new McpAbapAdtProxyServer();
26
+ await server.run();
27
+ ```
28
+
29
+ #### Methods
30
+
31
+ ##### `run(): Promise<void>`
32
+
33
+ Starts the proxy server and connects it to the transport.
34
+
35
+ **Returns:** Promise that resolves when server is started.
36
+
37
+ **Example:**
38
+ ```typescript
39
+ await server.run();
40
+ ```
41
+
42
+ ##### `shutdown(): Promise<void>`
43
+
44
+ Gracefully shuts down the server and closes all connections.
45
+
46
+ **Returns:** Promise that resolves when server is shut down.
47
+
48
+ **Example:**
49
+ ```typescript
50
+ await server.shutdown();
51
+ ```
52
+
53
+ ## Router Modules
54
+
55
+ ### Header Analyzer
56
+
57
+ #### `analyzeHeaders(headers: IncomingHttpHeaders, configOverrides?: { btpDestination?: string; targetUrl?: string }): RoutingDecision`
58
+
59
+ Analyzes HTTP headers to determine routing strategy. CLI overrides (`--btp`, `--target-url`) take precedence over headers.
60
+
61
+ **Parameters:**
62
+ - `headers`: HTTP request headers
63
+ - `configOverrides`: optional `btpDestination` / `targetUrl` from CLI params
64
+
65
+ **Returns:** `RoutingDecision` object with routing strategy and metadata.
66
+
67
+ **Example:**
68
+ ```typescript
69
+ import { analyzeHeaders } from "@mcp-abap-adt/proxy/router/headerAnalyzer";
70
+
71
+ const decision = analyzeHeaders(req.headers, { btpDestination: "ai" });
72
+ console.log(decision.strategy); // "proxy" | "unknown"
73
+ console.log(decision.btpDestination); // Destination for BTP Cloud authorization (from header or override)
74
+ console.log(decision.targetUrl); // Explicit target URL (from x-target-url or override)
75
+ ```
76
+
77
+ #### Routing Strategies
78
+
79
+ - `PROXY`: Proxy request with JWT authentication (`x-sap-destination` / `--btp` present)
80
+ - `UNKNOWN`: No BTP destination provided — request cannot be routed
81
+
82
+ ### Request Interceptor
83
+
84
+ #### `interceptRequest(req: IncomingMessage, body?: any): InterceptedRequest`
85
+
86
+ Intercepts and analyzes incoming HTTP request.
87
+
88
+ **Parameters:**
89
+ - `req`: HTTP request object
90
+ - `body`: Optional request body
91
+
92
+ **Returns:** `InterceptedRequest` object with routing decision and metadata.
93
+
94
+ **Example:**
95
+ ```typescript
96
+ import { interceptRequest } from "@mcp-abap-adt/proxy/router/requestInterceptor";
97
+
98
+ const intercepted = interceptRequest(req, body);
99
+ console.log(intercepted.routingDecision.strategy);
100
+ ```
101
+
102
+ ## Proxy Modules
103
+
104
+ ### BTP Proxy
105
+
106
+ #### `BtpProxy`
107
+
108
+ A facade over the destination's credential. It does not carry requests — that is
109
+ `forwardRequest()` below.
110
+
111
+ ##### `getAuthorizationHeader(destination): Promise<string | null>`
112
+
113
+ The `Authorization` header VALUE for a destination — `Bearer <token>`, complete —
114
+ or `null` where the credential is not a header at all.
115
+
116
+ Asked on every request, deliberately. The credential renews behind this call, so
117
+ a cache on the caller's side would serve the stale token and hide the renewal.
118
+
119
+ ##### `getTargetUrl(destination): Promise<string>`
120
+
121
+ Where that destination's requests go: `targetUrl` from the config if set,
122
+ otherwise `serviceUrl` from the service key. Throws when neither exists.
123
+
124
+ ##### `dispose(): void`
125
+
126
+ Lets go of the credentials and brokers held. There are no timers to cancel.
127
+
128
+ **Example:**
129
+ ```typescript
130
+ import { createBtpProxy } from "@mcp-abap-adt/proxy/proxy/btpProxy";
131
+
132
+ const proxy = await createBtpProxy(config);
133
+ const authorization = await proxy.getAuthorizationHeader("my-destination");
134
+ const targetUrl = await proxy.getTargetUrl("my-destination");
135
+ ```
136
+
137
+ ### Reverse Proxy
138
+
139
+ #### `forwardRequest(clientReq, clientRes, targetBaseUrl, authorization, defaultHeaders?, requestBody?)`
140
+
141
+ The single transparent pipe. Every transport goes through it.
142
+
143
+ **Parameters:**
144
+ - `clientReq` / `clientRes`: the incoming request and its response
145
+ - `targetBaseUrl`: where to forward
146
+ - `authorization`: a complete header VALUE, or `null` for no header at all. It is
147
+ NOT a token — composing `Bearer` around it would send `Bearer Bearer <token>`
148
+ - `defaultHeaders`: injected first; client headers win
149
+ - `requestBody`: for a caller that has already read the request. The SSE path
150
+ parses the JSON-RPC body because its error envelopes echo the `id`, and a
151
+ stream read once cannot be piped. Pass the bytes as they arrived, not a
152
+ re-serialised parse — the client's `content-length` is forwarded unchanged
153
+
154
+ The response streams; nothing is buffered on the way back.
155
+
156
+
157
+ ## Error Handling
158
+
159
+ ### ~~`CircuitBreaker`~~ — removed in 4.0.0
160
+
161
+ It guarded the buffered axios forward this release deletes. The forwarding path
162
+ now streams, and a breaker there would mean buffering the response again — the
163
+ thing being fixed. `circuitBreakerThreshold` and `circuitBreakerTimeout` are
164
+ still accepted in configuration so existing files load unchanged, and have no
165
+ effect.
166
+
167
+ ### ~~`ProxyRequest`~~ / ~~`ProxyResponse`~~ — removed in 4.0.0
168
+
169
+ These described the JSON-RPC envelope the deleted axios path rebuilt by hand and
170
+ answered with. Every transport now forwards through `forwardRequest()`, which
171
+ carries the request and the response as they are, so there is no envelope for
172
+ this package to name.