@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
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
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',
|
|
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
|
-
|
|
177
|
+
finish();
|
|
141
178
|
});
|
|
142
|
-
// Pipe client request body to backend
|
|
143
|
-
|
|
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
|
|
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(
|
|
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
|
|
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[
|
|
42
|
-
req.headers[
|
|
43
|
-
req.headers[
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|