@mcp-abap-adt/proxy 4.1.1 → 5.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.0] - 2026-09-26
11
+
12
+ **The proxy moves onto the new auth family, and its tree holds one copy of each
13
+ contract package.** `npm ls` shows exactly one version each of
14
+ `interfaces-auth` (2.1.0), `interfaces-auth-sap` (1.0.1), `interfaces-network`
15
+ (2.0.0), `auth-providers` (4.2.0) and `auth-stores` (1.2.3).
16
+
17
+ ### Changed
18
+
19
+ - **BREAKING: Node.js 22 or 24** — `engines: "^22 || ^24"` (was `>=18.2.0`).
20
+ `@mcp-abap-adt/auth-providers` 3.0.0 requires it, following the versions SAP
21
+ BTP's Cloud Foundry Node.js buildpack offers.
22
+
23
+ **Migrating:** run the proxy on Node 22 or 24. Nothing in its configuration
24
+ or CLI changed.
25
+ - **`@mcp-abap-adt/auth-broker` `^3.0.0`** (was `^2.2.0`, a major).
26
+ `BtpProxy` builds its brokers the new way —
27
+ `new AuthBroker({ serviceKeyStore, sessionStore, provider }, logger)` — with
28
+ a provider *factory*, so the broker seeds each destination's
29
+ `AuthorizationCodeProvider` with the refresh token and the access token the
30
+ session stored. The login is still a browser callback on `browserAuthPort`
31
+ (default 3333) with a five-minute window; `--browser none` / `headless` still
32
+ prints the URL instead of opening a browser.
33
+ - **`@mcp-abap-adt/auth-providers` `^4.2.0`** (was `^2.2.2`). The SAML changes
34
+ of 3.x and 4.x do not reach this package, which builds only
35
+ `AuthorizationCodeProvider`. A failed browser login is now
36
+ `BrowserAuthError` (a plain `Error` before).
37
+ - `@mcp-abap-adt/interfaces-auth` `^2.1.0` (was `^1.2.0`),
38
+ `@mcp-abap-adt/interfaces-auth-sap` `^1.0.1`, `@mcp-abap-adt/auth-stores`
39
+ `^1.2.3`, `@mcp-abap-adt/header-validator` `^0.3.1`,
40
+ `@mcp-abap-adt/connection` `^9.3.0` — the releases that accept
41
+ `interfaces-auth` 2, so none of them carries a private copy of it. None
42
+ changes an API this package uses.
43
+ - **With `--unsafe`, the session file no longer holds the client secret.**
44
+ auth-broker 3 writes the token and the refresh token only, and the proxy no
45
+ longer writes UAA credentials into the session either: with a `targetUrl`
46
+ it used to put the service key's client ID and secret there — or
47
+ `placeholder` credentials when it could not find them — through a private
48
+ broker method. The secret stays in the service key.
49
+ - **With `--unsafe`, the session file keeps its own URL.** The proxy also used
50
+ to write its `targetUrl` into the destination's session before building the
51
+ broker, so the broker could resolve a `serviceUrl` an XSUAA service key does
52
+ not carry. That session is the file `mcp-auth` writes too
53
+ (`~/.config/mcp-abap-adt/sessions/<destination>.env`), and its `SAP_URL`
54
+ was replaced by the MCP server's URL. The broker now reads the target URL
55
+ through `TargetUrlSessionStore`, a wrapper around the session store: reads
56
+ answer the target URL, writes keep the URL the session already holds, and
57
+ only a session that does not exist yet is created with the target URL.
58
+ Measured on the real `AbapSessionStore`: after a token write the file's
59
+ `SAP_URL` is unchanged.
60
+ - **Auth failures reach the log as the provider raised them.** auth-broker 3
61
+ no longer rewraps them into `Token provider … error for <destination>`. The
62
+ reason the standalone proxy prints before exiting is now chosen by error
63
+ class and `code` through the whole `cause` chain (`ServiceKeyNotFoundError`
64
+ and `ValidationError` → service key, `ECONNREFUSED` and kin → network), and
65
+ by message only where nothing typed says more (a login timeout, a UAA
66
+ refusal).
67
+
68
+ ### Fixed
69
+
70
+ - **An unreadable service key is reported as what it is.** The stores answer a
71
+ missing file with `null` and throw only when the file is there and wrong —
72
+ not valid JSON, not readable. The proxy answered every such throw as "service
73
+ key not found", with advice to create a file that already existed. The
74
+ store's error now reaches the caller as raised, naming the file; only `null`
75
+ means missing. Found in review.
76
+ - **`getAuthorizationHeader` passes the failure on as the same object.** It
77
+ wrapped it in a new `Error` with the same message, so `instanceof` on the
78
+ caller's side saw a plain `Error`, whatever the provider or store raised.
79
+
80
+ - **A missing service key is reported as one again.** The proxy recognised it
81
+ by the `.env` / `mcp.env` wording of auth-broker 2's message, which 3.0 no
82
+ longer produces — it would have said only that the session lacks a
83
+ `serviceUrl`. The proxy now checks the stores itself before building a
84
+ broker and raises its own `ServiceKeyNotFoundError`, with the same
85
+ "Service key file not found for destination … Please create service key
86
+ file: <destination>.json" message as before. It is still not retried.
87
+ - **`npm run test-destination` works.** It imported `XsuaaTokenProvider` and
88
+ `BtpTokenProvider`, removed from auth-providers long ago, and printed the
89
+ first 50 characters of the token. It now goes through `BtpProxy` itself and
90
+ prints the header's length only:
91
+ `npm run test-destination -- <destination> [--target-url <url>]`.
92
+
93
+ ## [4.2.0] - 2026-09-24
94
+
95
+ ### Added
96
+
97
+ - **`host` on `proxy_start`**, and the config's `httpHost` is honoured. The
98
+ default when neither says anything is still `127.0.0.1`.
99
+
100
+ It was ignored, on the reasoning that a proxy a model started should not be
101
+ network-reachable. That reasoning does not hold: loopback is a default, not a
102
+ boundary — anything with a shell can forward a port, so refusing to bind
103
+ elsewhere stopped only the honest case. What the default still does is keep a
104
+ proxy nobody asked to publish from becoming reachable without someone saying
105
+ so, which is worth a default and not worth a restriction.
106
+
107
+ Precedence, most specific first: the `host` argument, the config's `httpHost`,
108
+ then `127.0.0.1`.
109
+
10
110
  ## [4.1.1] - 2026-09-24
11
111
 
12
112
  ### Changed
package/README.md CHANGED
@@ -283,7 +283,7 @@ Create `mcp-proxy-config.json`:
283
283
 
284
284
  **Session Storage:**
285
285
  - `unsafe: false` (default) - Session data stored in-memory (secure, lost on restart)
286
- - `unsafe: true` - Session data persisted to disk (tokens saved under the session store path)
286
+ - `unsafe: true` - Session data persisted to disk (tokens saved under the session store path): the service URL, the token and the refresh token. The client secret is not copied there; it stays in the service key
287
287
 
288
288
  See [Configuration Guide](./docs/CONFIGURATION.md) for complete options.
289
289
 
@@ -296,7 +296,7 @@ See [Configuration Guide](./docs/CONFIGURATION.md) for complete options.
296
296
 
297
297
  ## Requirements
298
298
 
299
- - Node.js >= 18.0.0
299
+ - Node.js 22 or 24 (`engines: "^22 || ^24"`, required by `@mcp-abap-adt/auth-providers`)
300
300
  - npm >= 9.0.0
301
301
 
302
302
  ## Testing Tools
@@ -304,7 +304,7 @@ See [Configuration Guide](./docs/CONFIGURATION.md) for complete options.
304
304
  Verify a BTP destination's service key and token retrieval:
305
305
 
306
306
  ```bash
307
- npm run test-destination
307
+ npm run test-destination -- <destination>
308
308
  ```
309
309
 
310
310
  See [tools/README.md](./tools/README.md) for the available scripts.
@@ -41,7 +41,8 @@ Tools offered to the client:
41
41
  List the environments — one SAP system's credentials each,
42
42
  from sessions/. Variable NAMES only, never values.
43
43
  proxy_start Start a proxy from a config plus, when the config needs one,
44
- an environment. The config supplies
44
+ an environment. Binds 127.0.0.1 unless the config's httpHost or
45
+ a host argument says otherwise. The config supplies
45
46
  the destination, target URL, default headers and timeouts;
46
47
  the PORT does not come from it — a free one is bound, so
47
48
  several proxies can run at once, and the URL returned is the
@@ -1 +1 @@
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"}
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;AAqBlC;;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
@@ -18,6 +18,7 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
18
18
  const requestHandler_js_1 = require("./proxy/requestHandler.js");
19
19
  const reverseProxy_js_1 = require("./proxy/reverseProxy.js");
20
20
  const config_js_1 = require("./lib/config.js");
21
+ const authFailure_js_1 = require("./lib/authFailure.js");
21
22
  const logger_js_1 = require("./lib/logger.js");
22
23
  const stores_js_1 = require("./lib/stores.js");
23
24
  const transportConfig_js_1 = require("./lib/transportConfig.js");
@@ -33,38 +34,6 @@ function getBodyId(body) {
33
34
  }
34
35
  return null;
35
36
  }
36
- /**
37
- * Classify an authentication failure into a human-readable reason so the proxy
38
- * can report *why* it is exiting (timeout vs bad credentials vs network ...).
39
- */
40
- function classifyAuthFailure(error) {
41
- const msg = error instanceof Error ? error.message : String(error);
42
- if (/timeout/i.test(msg)) {
43
- return {
44
- category: 'timeout',
45
- reason: 'the login URL was not completed in time',
46
- };
47
- }
48
- if (/service key|missing required fields/i.test(msg)) {
49
- return {
50
- category: 'service-key',
51
- reason: 'service key missing or incomplete',
52
- };
53
- }
54
- if (/invalid_client|unauthorized|\b401\b|invalid credentials/i.test(msg)) {
55
- return {
56
- category: 'credentials',
57
- reason: 'invalid credentials — UAA rejected the client',
58
- };
59
- }
60
- if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network|ENETUNREACH/i.test(msg)) {
61
- return {
62
- category: 'network',
63
- reason: 'network error reaching the authentication server',
64
- };
65
- }
66
- return { category: 'other', reason: msg };
67
- }
68
37
  /**
69
38
  * MCP ABAP ADT Proxy Server
70
39
  */
@@ -564,7 +533,7 @@ Authorization source: ${authSourceObj}`;
564
533
  if (this.authExiting)
565
534
  return;
566
535
  this.authExiting = true;
567
- const { category, reason } = classifyAuthFailure(error);
536
+ const { category, reason } = (0, authFailure_js_1.classifyAuthFailure)(error);
568
537
  const detail = error instanceof Error ? error.message : String(error);
569
538
  logger_js_1.logger?.error('Authentication not completed — exiting', {
570
539
  type: 'AUTH_FATAL_EXIT',
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Authentication failures: the one the proxy raises itself, and how any of
3
+ * them is told apart for the message printed before the standalone proxy
4
+ * exits.
5
+ */
6
+ /**
7
+ * No UAA credentials for a destination in either store.
8
+ *
9
+ * The proxy's own error, thrown before the broker is asked: auth-broker 3
10
+ * hands a provider's failure back unchanged and reports a missing service key
11
+ * only as a missing `serviceUrl`, so matching its message for this case — as
12
+ * the proxy used to — finds nothing any more. Not retried: a key that is not
13
+ * there now will not be there in a second.
14
+ */
15
+ export declare class ServiceKeyNotFoundError extends Error {
16
+ readonly destination: string;
17
+ readonly searched: string;
18
+ readonly code = "SERVICE_KEY_NOT_FOUND";
19
+ constructor(destination: string, searched?: string);
20
+ }
21
+ export type AuthFailureCategory = 'timeout' | 'credentials' | 'service-key' | 'network' | 'other';
22
+ /**
23
+ * Classify an authentication failure into a human-readable reason so the proxy
24
+ * can report *why* it is exiting (timeout vs bad credentials vs network ...).
25
+ *
26
+ * By type and `code` first, through the whole `cause` chain: auth-broker 3
27
+ * passes provider errors on unchanged, so their classes and codes arrive
28
+ * intact. The message is read only where nothing typed says more — a browser
29
+ * login's timeout and a UAA refusal are both a plain message inside their
30
+ * error.
31
+ */
32
+ export declare function classifyAuthFailure(error: unknown): {
33
+ category: AuthFailureCategory;
34
+ reason: string;
35
+ };
36
+ //# sourceMappingURL=authFailure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authFailure.d.ts","sourceRoot":"","sources":["../../src/lib/authFailure.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH;;;;;;;;GAQG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;IAI9C,QAAQ,CAAC,WAAW,EAAE,MAAM;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM;IAJ3B,QAAQ,CAAC,IAAI,2BAA2B;gBAG7B,WAAW,EAAE,MAAM,EACnB,QAAQ,GAAE,MAA4C;CAYlE;AAED,MAAM,MAAM,mBAAmB,GAC3B,SAAS,GACT,aAAa,GACb,aAAa,GACb,SAAS,GACT,OAAO,CAAC;AA8BZ;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG;IACnD,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;CAChB,CA8CA"}
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * Authentication failures: the one the proxy raises itself, and how any of
4
+ * them is told apart for the message printed before the standalone proxy
5
+ * exits.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.ServiceKeyNotFoundError = void 0;
9
+ exports.classifyAuthFailure = classifyAuthFailure;
10
+ const auth_providers_1 = require("@mcp-abap-adt/auth-providers");
11
+ const stores_js_1 = require("./stores.js");
12
+ /**
13
+ * No UAA credentials for a destination in either store.
14
+ *
15
+ * The proxy's own error, thrown before the broker is asked: auth-broker 3
16
+ * hands a provider's failure back unchanged and reports a missing service key
17
+ * only as a missing `serviceUrl`, so matching its message for this case — as
18
+ * the proxy used to — finds nothing any more. Not retried: a key that is not
19
+ * there now will not be there in a second.
20
+ */
21
+ class ServiceKeyNotFoundError extends Error {
22
+ destination;
23
+ searched;
24
+ code = 'SERVICE_KEY_NOT_FOUND';
25
+ constructor(destination, searched = (0, stores_js_1.getPlatformPaths)('service-keys')[0]) {
26
+ super([
27
+ `Service key file not found for destination "${destination}".`,
28
+ `Please create service key file: ${destination}.json`,
29
+ 'Searched in:',
30
+ ` - ${searched}`,
31
+ ].join('\n'));
32
+ this.destination = destination;
33
+ this.searched = searched;
34
+ this.name = 'ServiceKeyNotFoundError';
35
+ }
36
+ }
37
+ exports.ServiceKeyNotFoundError = ServiceKeyNotFoundError;
38
+ const NETWORK_CODES = new Set([
39
+ 'ENOTFOUND',
40
+ 'ECONNREFUSED',
41
+ 'ECONNRESET',
42
+ 'ETIMEDOUT',
43
+ 'ENETUNREACH',
44
+ 'EAI_AGAIN',
45
+ ]);
46
+ /** The error and every `cause` behind it, nearest first. */
47
+ function causeChain(error) {
48
+ const chain = [];
49
+ let current = error;
50
+ while (current !== undefined && current !== null && chain.length < 10) {
51
+ chain.push(current);
52
+ current = current instanceof Error ? current.cause : undefined;
53
+ }
54
+ return chain;
55
+ }
56
+ function codeOf(error) {
57
+ if (error !== null && typeof error === 'object' && 'code' in error) {
58
+ const { code } = error;
59
+ return typeof code === 'string' ? code : undefined;
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Classify an authentication failure into a human-readable reason so the proxy
65
+ * can report *why* it is exiting (timeout vs bad credentials vs network ...).
66
+ *
67
+ * By type and `code` first, through the whole `cause` chain: auth-broker 3
68
+ * passes provider errors on unchanged, so their classes and codes arrive
69
+ * intact. The message is read only where nothing typed says more — a browser
70
+ * login's timeout and a UAA refusal are both a plain message inside their
71
+ * error.
72
+ */
73
+ function classifyAuthFailure(error) {
74
+ const chain = causeChain(error);
75
+ if (chain.some((e) => e instanceof ServiceKeyNotFoundError || e instanceof auth_providers_1.ValidationError)) {
76
+ return {
77
+ category: 'service-key',
78
+ reason: 'service key missing or incomplete',
79
+ };
80
+ }
81
+ if (chain.some((e) => NETWORK_CODES.has(codeOf(e) ?? ''))) {
82
+ return {
83
+ category: 'network',
84
+ reason: 'network error reaching the authentication server',
85
+ };
86
+ }
87
+ const msg = error instanceof Error ? error.message : String(error);
88
+ if (/timeout|timed out/i.test(msg)) {
89
+ return {
90
+ category: 'timeout',
91
+ reason: 'the login URL was not completed in time',
92
+ };
93
+ }
94
+ if (/service key|missing required fields/i.test(msg)) {
95
+ return {
96
+ category: 'service-key',
97
+ reason: 'service key missing or incomplete',
98
+ };
99
+ }
100
+ if (/invalid_client|unauthorized|\b401\b|invalid credentials/i.test(msg)) {
101
+ return {
102
+ category: 'credentials',
103
+ reason: 'invalid credentials — UAA rejected the client',
104
+ };
105
+ }
106
+ if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network|ENETUNREACH/i.test(msg)) {
107
+ return {
108
+ category: 'network',
109
+ reason: 'network error reaching the authentication server',
110
+ };
111
+ }
112
+ return { category: 'other', reason: msg };
113
+ }
@@ -35,6 +35,15 @@ export interface StartOptions {
35
35
  idleTimeoutMs?: number;
36
36
  /** How long a stop waits for requests in flight. See DEFAULT_STOP_GRACE_MS. */
37
37
  stopGraceMs?: number;
38
+ /**
39
+ * What to bind, ahead of the config's `httpHost` and the loopback default.
40
+ *
41
+ * Loopback is a default, not a boundary: anything with a shell can forward a
42
+ * port, so refusing to bind elsewhere stopped only the honest case. What the
43
+ * default still does is keep a proxy nobody asked to publish from becoming
44
+ * reachable without someone saying so.
45
+ */
46
+ host?: string;
38
47
  }
39
48
  export interface StartedInstance {
40
49
  instanceId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../../src/mcp/supervisor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAY,KAAK,cAAc,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEhF,gDAAgD;AAChD,eAAO,MAAM,uBAAuB,QAAiB,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,MAAM,WAAW,YAAY;IAC3B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAoBD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,yEAAyE;IACzE,QAAQ,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,eAAe;IAKd,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmB;IAC5C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;gBAED,OAAO,EAAE,iBAAiB;IAKjD,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IAwG5D,yDAAyD;IACnD,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAiD3D,oCAAoC;IACpC,IAAI,IAAI,eAAe,EAAE;IAIzB;;;;;;;;OAQG;IACH,MAAM,IAAI,cAAc,EAAE;IAI1B;;;;OAIG;IACH,OAAO,CAAC,OAAO;IAkBf;;;;;;;OAOG;YACW,aAAa;IA4B3B,OAAO,CAAC,QAAQ;IAahB;;;OAGG;IACH,OAAO,CAAC,OAAO;IAsBf;;;;;;;OAOG;IACH,OAAO,CAAC,cAAc;IAUtB,wDAAwD;IACxD,OAAO,CAAC,eAAe;CAQxB"}
1
+ {"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../../src/mcp/supervisor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAY,KAAK,cAAc,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEhF,gDAAgD;AAChD,eAAO,MAAM,uBAAuB,QAAiB,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,MAAM,WAAW,YAAY;IAC3B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAoBD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,yEAAyE;IACzE,QAAQ,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,eAAe;IAKd,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmB;IAC5C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;gBAED,OAAO,EAAE,iBAAiB;IAKjD,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IA2G5D,yDAAyD;IACnD,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAiD3D,oCAAoC;IACpC,IAAI,IAAI,eAAe,EAAE;IAIzB;;;;;;;;OAQG;IACH,MAAM,IAAI,cAAc,EAAE;IAI1B;;;;OAIG;IACH,OAAO,CAAC,OAAO;IAkBf;;;;;;;OAOG;YACW,aAAa;IA4B3B,OAAO,CAAC,QAAQ;IAahB;;;OAGG;IACH,OAAO,CAAC,OAAO;IAsBf;;;;;;;OAOG;IACH,OAAO,CAAC,cAAc;IAUtB,wDAAwD;IACxD,OAAO,CAAC,eAAe;CAQxB"}
@@ -98,9 +98,11 @@ class ProxySupervisor {
98
98
  // filesystem used to reject `start()` while leaving the listener bound, the
99
99
  // credential held, the instance in `owned` and no idle timer to ever reach
100
100
  // it — a proxy running that nobody had been told about.
101
+ // Most specific first: this call, then the config, then the default.
102
+ const host = options.host ?? options.config.httpHost ?? this.host;
101
103
  let port;
102
104
  try {
103
- port = await (0, ports_js_1.listenOnFreePort)(server, this.host);
105
+ port = await (0, ports_js_1.listenOnFreePort)(server, host);
104
106
  }
105
107
  catch (error) {
106
108
  this.release(facade, server, 'listen failed');
@@ -110,7 +112,7 @@ class ProxySupervisor {
110
112
  instanceId,
111
113
  name: options.name,
112
114
  port,
113
- url: `http://${this.host}:${port}`,
115
+ url: `http://${host}:${port}`,
114
116
  destination: options.config.btpDestination ?? '(none)',
115
117
  startedAt: new Date().toISOString(),
116
118
  server,
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/mcp/tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAaxB,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEhF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,QAGc,CAAC;AAE7C,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,CAAC,CAAC,WAAW,CAAC;IAC3B,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;CACjE;AAMD,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,eAAe,EAC3B,SAAS,GAAE,MAAyB,EACpC,MAAM,GAAE,MAAyB,GAChC,SAAS,EAAE,CAmMb"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/mcp/tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAaxB,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEhF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,QAGc,CAAC;AAE7C,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,CAAC,CAAC,WAAW,CAAC;IAC3B,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;CACjE;AAMD,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,eAAe,EAC3B,SAAS,GAAE,MAAyB,EACpC,MAAM,GAAE,MAAyB,GAChC,SAAS,EAAE,CA0Mb"}
package/dist/mcp/tools.js CHANGED
@@ -93,6 +93,10 @@ function createProxyTools(supervisor, configDir = (0, configs_js_1.proxyConfigDi
93
93
  .string()
94
94
  .optional()
95
95
  .describe('Name of an environment, as proxy_environments lists it (for example "e19"). Supplies the ${VAR} values the config references. Needed when the config names no envFile of its own — proxy_configs marks those.'),
96
+ host: zod_1.z
97
+ .string()
98
+ .optional()
99
+ .describe('What to bind. Defaults to 127.0.0.1, or the config\u2019s httpHost when it names one. Use "0.0.0.0" to make the proxy reachable from other machines — say so deliberately, since a started proxy is otherwise local only.'),
96
100
  idleTimeoutMs: zod_1.z
97
101
  .number()
98
102
  .optional()
@@ -108,6 +112,7 @@ function createProxyTools(supervisor, configDir = (0, configs_js_1.proxyConfigDi
108
112
  const started = await supervisor.start({
109
113
  name,
110
114
  config: (0, config_js_1.loadConfig)(file, envFile),
115
+ host: args.host,
111
116
  idleTimeoutMs: args.idleTimeoutMs,
112
117
  });
113
118
  return text([
@@ -4,8 +4,18 @@
4
4
  * For requests with x-sap-destination, proxies to target MCP server
5
5
  * with JWT token from auth-broker (XSUAA/BTP)
6
6
  */
7
- import { AuthBroker } from '@mcp-abap-adt/auth-broker';
7
+ import { AuthBroker, type TokenProviderFactory } from '@mcp-abap-adt/auth-broker';
8
8
  import { type ProxyConfig } from '../lib/config.js';
9
+ /**
10
+ * The provider auth-broker builds per destination: an
11
+ * `AuthorizationCodeProvider` seeded with what the stores hold — the UAA
12
+ * credentials with the stored refresh token, and the stored access token — so
13
+ * a session that is still valid, or can be refreshed, needs no browser.
14
+ *
15
+ * How the login is conducted is the provider's authorization strategy; the
16
+ * broker has no say in it any more (its `browser` argument is gone).
17
+ */
18
+ export declare function authorizationCodeProviderFactory(config: Partial<ProxyConfig>): TokenProviderFactory;
9
19
  /**
10
20
  * Check if error messages should be written to stderr
11
21
  * Only output in verbose mode and not in test environment
@@ -32,10 +42,6 @@ export declare class BtpProxy {
32
42
  * If broker doesn't exist in map, creates new one and stores it
33
43
  */
34
44
  private getOrCreateBtpAuthBroker;
35
- /**
36
- * Helper to ensure valid serviceUrl in session if override provided
37
- */
38
- private ensureSessionServiceUrl;
39
45
  /**
40
46
  * The `Authorization` header value for a destination, or `null` when this
41
47
  * credential is not a header at all.
@@ -54,15 +60,6 @@ export declare class BtpProxy {
54
60
  * them, so it fails on the first attempt instead of three times slower.
55
61
  */
56
62
  getAuthorizationHeader(destination: string): Promise<string | null>;
57
- /**
58
- * Turn the broker's failure into one that names what this proxy needs.
59
- *
60
- * The broker speaks of `.env` and `mcp.env` because it can be fed either way.
61
- * This proxy only ever reads service keys, so passing that message through
62
- * told the most common failure — a missing key — to go and create a file that
63
- * would be ignored.
64
- */
65
- private explainAuthFailure;
66
63
  dispose(): void;
67
64
  /**
68
65
  * Get the target service URL for a destination.
@@ -1 +1 @@
1
- {"version":3,"file":"btpProxy.d.ts","sourceRoot":"","sources":["../../src/proxy/btpProxy.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,UAAU,EAAgB,MAAM,2BAA2B,CAAC;AAOrE,OAAO,EAAc,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AA+DhE;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAU3C;AAED;;GAEG;AACH,qBAAa,QAAQ;IACnB,OAAO,CAAC,oBAAoB,CAAa;IACzC,OAAO,CAAC,cAAc,CAAsC;IAC5D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAyB;IACrD,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,MAAM,CAAU;gBAEZ,oBAAoB,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC;IAsC3E;;;OAGG;IACU,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwB3D;;;;OAIG;YACW,wBAAwB;IAiGtC;;OAEG;YACW,uBAAuB;IAyFrC;;;;;;;;;;;;;;;;OAgBG;IACG,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IA+BzE;;;;;;;OAOG;IACH,OAAO,CAAC,kBAAkB;IAuBnB,OAAO,IAAI,IAAI;IAKtB;;;OAGG;IACG,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAexD;;OAEG;WACiB,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC;CAuC7E;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAC5B,OAAO,CAAC,QAAQ,CAAC,CAEnB"}
1
+ {"version":3,"file":"btpProxy.d.ts","sourceRoot":"","sources":["../../src/proxy/btpProxy.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,UAAU,EAIV,KAAK,oBAAoB,EAC1B,MAAM,2BAA2B,CAAC;AAMnC,OAAO,EAAc,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AA8EhE;;;;;;;;GAQG;AACH,wBAAgB,gCAAgC,CAC9C,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC,GAC3B,oBAAoB,CAuBtB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAU3C;AAED;;GAEG;AACH,qBAAa,QAAQ;IACnB,OAAO,CAAC,oBAAoB,CAAa;IACzC,OAAO,CAAC,cAAc,CAAsC;IAC5D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAyB;IACrD,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,MAAM,CAAU;gBAEZ,oBAAoB,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC;IAsC3E;;;OAGG;IACU,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwB3D;;;;OAIG;YACW,wBAAwB;IAmDtC;;;;;;;;;;;;;;;;OAgBG;IACG,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAkClE,OAAO,IAAI,IAAI;IAKtB;;;OAGG;IACG,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAexD;;OAEG;WACiB,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC;CAkB7E;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAC5B,OAAO,CAAC,QAAQ,CAAC,CAEnB"}
@@ -7,10 +7,12 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.BtpProxy = void 0;
10
+ exports.authorizationCodeProviderFactory = authorizationCodeProviderFactory;
10
11
  exports.shouldWriteStderr = shouldWriteStderr;
11
12
  exports.createBtpProxy = createBtpProxy;
12
13
  const auth_broker_1 = require("@mcp-abap-adt/auth-broker");
13
14
  const auth_providers_1 = require("@mcp-abap-adt/auth-providers");
15
+ const authFailure_js_1 = require("../lib/authFailure.js");
14
16
  const config_js_1 = require("../lib/config.js");
15
17
  const logger_js_1 = require("../lib/logger.js");
16
18
  const stores_js_1 = require("../lib/stores.js");
@@ -49,6 +51,52 @@ const loggerAdapter = {
49
51
  : undefined),
50
52
  };
51
53
  const errorHandler_js_1 = require("../lib/errorHandler.js");
54
+ const targetUrlSessionStore_js_1 = require("./targetUrlSessionStore.js");
55
+ async function requireCredentials(destination, serviceKeyStore, sessionStore) {
56
+ // A store answers a missing file with null. Anything it throws — a key
57
+ // that is not valid JSON, a file it may not read — is a different problem
58
+ // with a different fix, and reaches the caller as the store raised it,
59
+ // naming the file; answering it as "not found" sent users to create a file
60
+ // that was already there.
61
+ const found = (await serviceKeyStore.getAuthorizationConfig(destination)) ??
62
+ (await sessionStore.getAuthorizationConfig(destination));
63
+ if (!found) {
64
+ throw new authFailure_js_1.ServiceKeyNotFoundError(destination);
65
+ }
66
+ }
67
+ /**
68
+ * The provider auth-broker builds per destination: an
69
+ * `AuthorizationCodeProvider` seeded with what the stores hold — the UAA
70
+ * credentials with the stored refresh token, and the stored access token — so
71
+ * a session that is still valid, or can be refreshed, needs no browser.
72
+ *
73
+ * How the login is conducted is the provider's authorization strategy; the
74
+ * broker has no say in it any more (its `browser` argument is gone).
75
+ */
76
+ function authorizationCodeProviderFactory(config) {
77
+ return (destination, authConfig, connConfig) => {
78
+ if (!authConfig) {
79
+ throw new authFailure_js_1.ServiceKeyNotFoundError(destination);
80
+ }
81
+ return new auth_providers_1.AuthorizationCodeProvider({
82
+ authorization: (0, auth_providers_1.browserCallbackStrategy)({
83
+ browser: config.browser,
84
+ port: config.browserAuthPort || DEFAULT_BROWSER_AUTH_PORT,
85
+ timeoutMs: INTERACTIVE_LOGIN_TIMEOUT_MS,
86
+ }),
87
+ // Pass a logger so the callback strategy can surface the "Open this
88
+ // URL" prompt in 'none'/'headless' mode. Without it, the callback
89
+ // server gets a null logger and silently drops the authorization URL.
90
+ logger: loggerAdapter,
91
+ uaaUrl: authConfig.uaaUrl,
92
+ clientId: authConfig.uaaClientId,
93
+ clientSecret: authConfig.uaaClientSecret,
94
+ refreshToken: authConfig.refreshToken,
95
+ // An empty string is how a session seeded without a token says "none".
96
+ accessToken: connConfig.authorizationToken || undefined,
97
+ });
98
+ };
99
+ }
52
100
  /**
53
101
  * Check if error messages should be written to stderr
54
102
  * Only output in verbose mode and not in test environment
@@ -134,15 +182,15 @@ class BtpProxy {
134
182
  * If destination is not provided, returns default broker
135
183
  * If broker doesn't exist in map, creates new one and stores it
136
184
  */
137
- async getOrCreateBtpAuthBroker(destination, targetUrl) {
185
+ async getOrCreateBtpAuthBroker(destination) {
138
186
  // If no destination, use default broker
139
187
  if (!destination) {
140
188
  return this.defaultBtpAuthBroker;
141
189
  }
142
190
  // Check if broker exists in map
143
- let broker = this.btpAuthBrokers.get(destination);
144
- if (broker) {
145
- return broker;
191
+ const existing = this.btpAuthBrokers.get(destination);
192
+ if (existing) {
193
+ return existing;
146
194
  }
147
195
  // Create new broker for this destination
148
196
  logger_js_1.logger?.info('Creating new BTP auth broker for destination', {
@@ -150,146 +198,24 @@ class BtpProxy {
150
198
  destination,
151
199
  });
152
200
  const { serviceKeyStore, sessionStore } = await (0, stores_js_1.getPlatformStores)(this.unsafe);
153
- // We must manually load the credentials because AuthorizationCodeProvider validates them in constructor
154
- let authConfig = null;
155
- try {
156
- // Try service key store first
157
- if (serviceKeyStore) {
158
- authConfig = await serviceKeyStore.getAuthorizationConfig(destination);
159
- }
160
- // If not found, try session store (though less likely for initial setup)
161
- if (!authConfig) {
162
- authConfig = await sessionStore.getAuthorizationConfig(destination);
163
- }
164
- }
165
- catch (error) {
166
- logger_js_1.logger?.warn('Failed to load auth config for provider initialization', {
167
- error: error instanceof Error ? error.message : String(error),
168
- destination,
169
- });
170
- }
171
- if (!authConfig) {
172
- const serviceKeyDir = (0, stores_js_1.getPlatformPaths)('service-keys')[0];
173
- logger_js_1.logger?.error('Service key not found for destination', {
174
- destination,
175
- hint: `Ensure service key file "${destination}.json" exists in ${serviceKeyDir} (override the base dir with AUTH_BROKER_PATH)`,
176
- });
177
- // We cannot proceed without config, but we'll let it fail with a clear message
178
- // Or we could throw here.
179
- // If we don't provide config, provider will throw "Missing required fields".
180
- }
181
- // Always use AuthorizationCodeProvider (enforced)
182
- // Map IAuthorizationConfig (uaaClientId) to ProviderConfig (clientId)
183
- // How the login is conducted is now a strategy the provider config
184
- // carries under `authorization`; the callback port lives inside it.
185
- //
186
- // uaaUrl/clientId/clientSecret fall back to '' rather than being omitted
187
- // when authConfig is missing: AuthorizationCodeProviderConfig declares
188
- // them required, and the provider's own constructor already treats an
189
- // empty string the same as absent, throwing its "Missing required
190
- // fields" ValidationError — which is the clear failure this was always
191
- // meant to produce.
192
- const providerConfig = {
193
- authorization: (0, auth_providers_1.browserCallbackStrategy)({
194
- browser: this.config.browser,
195
- port: this.config.browserAuthPort || DEFAULT_BROWSER_AUTH_PORT,
196
- timeoutMs: INTERACTIVE_LOGIN_TIMEOUT_MS,
197
- }),
198
- // Pass a logger so the callback strategy can surface the "Open this
199
- // URL" prompt in 'none'/'headless' mode. Without it, the callback
200
- // server gets a null logger and silently drops the authorization URL.
201
- logger: loggerAdapter,
202
- uaaUrl: authConfig?.uaaUrl ?? '',
203
- clientId: authConfig?.uaaClientId ?? '',
204
- clientSecret: authConfig?.uaaClientSecret ?? '',
205
- };
206
- const tokenProvider = new auth_providers_1.AuthorizationCodeProvider(providerConfig);
207
- broker = new auth_broker_1.AuthBroker({
201
+ // auth-broker reports a missing service key only as a missing
202
+ // `serviceUrl` — and not at all once a targetUrl is seeded below — so the
203
+ // proxy asks the stores itself, before anything is written, and fails
204
+ // with its own error naming the file to create.
205
+ await requireCredentials(destination, serviceKeyStore, sessionStore);
206
+ // The broker is told the target URL through the store it reads, not by
207
+ // writing it into the session: that session may be the file mcp-auth
208
+ // writes, whose own URL must stay.
209
+ const targetUrl = this.config.targetUrl;
210
+ const brokerSessionStore = targetUrl
211
+ ? new targetUrlSessionStore_js_1.TargetUrlSessionStore(sessionStore, targetUrl)
212
+ : sessionStore;
213
+ const broker = new auth_broker_1.AuthBroker({
208
214
  serviceKeyStore,
209
- sessionStore,
210
- tokenProvider,
211
- }, this.config.browser, // Pass configured browser (default: 'system')
212
- logger_js_1.logger);
215
+ sessionStore: brokerSessionStore,
216
+ provider: authorizationCodeProviderFactory(this.config),
217
+ }, loggerAdapter);
213
218
  this.btpAuthBrokers.set(destination, broker);
214
- return this.ensureSessionServiceUrl(broker, destination, targetUrl);
215
- }
216
- /**
217
- * Helper to ensure valid serviceUrl in session if override provided
218
- */
219
- async ensureSessionServiceUrl(broker, destination, targetUrl) {
220
- const activeTargetUrl = targetUrl || this.config.targetUrl;
221
- if (!activeTargetUrl) {
222
- return broker;
223
- }
224
- try {
225
- // Check if current connection config exists
226
- const currentConn = await broker.getConnectionConfig(destination);
227
- // We need to ensure we have a valid session with BOTH serviceUrl AND auth config.
228
- // Even if serviceUrl matches, the auth config might be missing from the session
229
- // (which causes ClientCredentialsProvider to fail if initialized with empty config).
230
- // Cast to any to access potentially private methods if interface restricted
231
- // biome-ignore lint/suspicious/noExplicitAny: Accessing internal methods for safe injection
232
- const brokerAny = broker;
233
- let authConfig = await broker.getAuthorizationConfig(destination);
234
- if (!authConfig) {
235
- try {
236
- if (typeof brokerAny.getAuthorizationConfigFromServiceKey === 'function') {
237
- authConfig =
238
- await brokerAny.getAuthorizationConfigFromServiceKey(destination);
239
- }
240
- }
241
- catch (e) {
242
- logger_js_1.logger?.debug('Could not find auth config for session update', {
243
- error: String(e),
244
- });
245
- }
246
- }
247
- if (!authConfig) {
248
- authConfig = {
249
- uaaUrl: 'https://placeholder.authentication.sap.hana.ondemand.com',
250
- uaaClientId: 'placeholder',
251
- uaaClientSecret: 'placeholder',
252
- };
253
- logger_js_1.logger?.info('Using placeholder auth config for session injection', {
254
- type: 'BTP_SESSION_PLACEHOLDER',
255
- destination,
256
- targetUrl: activeTargetUrl,
257
- });
258
- }
259
- if (authConfig) {
260
- const newConn = {
261
- ...(currentConn || {}),
262
- serviceUrl: activeTargetUrl,
263
- authType: 'jwt',
264
- // Map XSUAA keys to ClientCredentialsProvider keys
265
- clientId: authConfig.uaaClientId,
266
- clientSecret: authConfig.uaaClientSecret,
267
- uaaUrl: authConfig.uaaUrl,
268
- };
269
- if (typeof brokerAny.saveTokenToSession === 'function') {
270
- await brokerAny.saveTokenToSession(destination, newConn, authConfig);
271
- logger_js_1.logger?.info('Injected targetUrl and auth config into BTP session', {
272
- type: 'BTP_SESSION_INJECTION',
273
- destination,
274
- url: activeTargetUrl,
275
- hasClientId: !!newConn.clientId,
276
- });
277
- }
278
- else {
279
- logger_js_1.logger?.error('saveTokenToSession is not a function on broker', {
280
- type: 'BTP_SESSION_METHOD_MISSING',
281
- keys: Object.keys(brokerAny),
282
- });
283
- }
284
- }
285
- }
286
- catch (error) {
287
- logger_js_1.logger?.error('Failed to inject targetUrl into session', {
288
- type: 'BTP_SESSION_INJECTION_ERROR',
289
- destination,
290
- error: error instanceof Error ? error.message : String(error),
291
- });
292
- }
293
219
  return broker;
294
220
  }
295
221
  /**
@@ -326,7 +252,10 @@ class BtpProxy {
326
252
  }, retryOptions);
327
253
  }
328
254
  catch (error) {
329
- const message = this.explainAuthFailure(error, destination);
255
+ // Passed on as it stands: auth-broker 3 no longer rewords a provider's
256
+ // failure, and a missing service key is already this proxy's own
257
+ // ServiceKeyNotFoundError, which names the file to create.
258
+ const message = error instanceof Error ? error.message : String(error);
330
259
  logger_js_1.logger?.error('Failed to get an authorization header', {
331
260
  type: 'CREDENTIAL_HEADER_ERROR',
332
261
  destination,
@@ -335,36 +264,8 @@ class BtpProxy {
335
264
  if (shouldWriteStderr()) {
336
265
  process.stderr.write(`[MCP Proxy] ✗ ${message}\n`);
337
266
  }
338
- throw new Error(message);
339
- }
340
- }
341
- /**
342
- * Turn the broker's failure into one that names what this proxy needs.
343
- *
344
- * The broker speaks of `.env` and `mcp.env` because it can be fed either way.
345
- * This proxy only ever reads service keys, so passing that message through
346
- * told the most common failure — a missing key — to go and create a file that
347
- * would be ignored.
348
- */
349
- explainAuthFailure(error, destination) {
350
- const original = error instanceof Error ? error.message : String(error);
351
- if (!original.includes('.env') && !original.includes('mcp.env')) {
352
- return original;
267
+ throw error;
353
268
  }
354
- const searched = original.match(/Searched in:\s*([\s\S]*?)(?:\n\n|$)/);
355
- const paths = searched
356
- ? searched[1]
357
- .trim()
358
- .split('\n')
359
- .map((p) => p.trim().replace(/^-\s*/, ''))
360
- .filter(Boolean)
361
- : [(0, stores_js_1.storeDir)('service-keys')];
362
- return [
363
- `Service key file not found for destination "${destination}".`,
364
- `Please create service key file: ${destination}.json`,
365
- 'Searched in:',
366
- ...paths.map((p) => ` - ${p}`),
367
- ].join('\n');
368
269
  }
369
270
  dispose() {
370
271
  this.credentials.clear();
@@ -396,28 +297,8 @@ class BtpProxy {
396
297
  const defaultBtpAuthBroker = new auth_broker_1.AuthBroker({
397
298
  serviceKeyStore,
398
299
  sessionStore,
399
- tokenProvider: new auth_providers_1.AuthorizationCodeProvider({
400
- uaaUrl: 'https://placeholder.authentication.sap.hana.ondemand.com',
401
- clientId: 'placeholder',
402
- clientSecret: 'placeholder',
403
- authorization: (0, auth_providers_1.browserCallbackStrategy)({
404
- browser: loadedConfig.browser,
405
- // This call site never had its own fallback: it passed
406
- // `redirectPort` straight through and relied on
407
- // auth-providers@1.2.0 defaulting an omitted value to 3001
408
- // internally — which is this proxy's own default `httpPort`
409
- // (src/lib/config.ts). An omitted `browserAuthPort` therefore
410
- // told the callback server to bind the port the proxy already
411
- // listens on, failing every such login with "Port 3001 is
412
- // already in use". Both call sites now share the one documented
413
- // default instead of leaving this one to an invisible, colliding
414
- // number.
415
- port: loadedConfig.browserAuthPort || DEFAULT_BROWSER_AUTH_PORT,
416
- timeoutMs: INTERACTIVE_LOGIN_TIMEOUT_MS,
417
- }),
418
- logger: loggerAdapter,
419
- }),
420
- }, 'none', loggerAdapter);
300
+ provider: authorizationCodeProviderFactory(loadedConfig),
301
+ }, loggerAdapter);
421
302
  return new BtpProxy(defaultBtpAuthBroker, loadedConfig);
422
303
  }
423
304
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A session store that tells the broker the proxy's `targetUrl` without
3
+ * writing it over the destination's own URL.
4
+ *
5
+ * auth-broker 3 refuses a destination whose session and service key both lack
6
+ * a `serviceUrl`, and an XSUAA service key for a BTP-hosted MCP server usually
7
+ * carries none: the target URL is what the proxy is told instead. The proxy
8
+ * used to write it into the session before building the broker — and, with
9
+ * `unsafe`, that session is the file `mcp-auth` writes too, so its `SAP_URL`
10
+ * was replaced by the MCP server's URL.
11
+ *
12
+ * Reads answer the target URL as the `serviceUrl`. Writes keep the URL the
13
+ * session already holds; only a session that does not exist yet is created
14
+ * with the target URL, since a store cannot create one without a URL.
15
+ * Everything else passes through to the store it wraps.
16
+ */
17
+ import type { IAuthorizationConfig, IConfig, IConnectionConfig, ISessionStore } from '@mcp-abap-adt/interfaces-auth-sap';
18
+ export declare class TargetUrlSessionStore implements ISessionStore {
19
+ private readonly inner;
20
+ private readonly targetUrl;
21
+ constructor(inner: ISessionStore, targetUrl: string);
22
+ loadSession(destination: string): Promise<IConfig | null>;
23
+ getConnectionConfig(destination: string): Promise<IConnectionConfig | null>;
24
+ getAuthorizationConfig(destination: string): Promise<IAuthorizationConfig | null>;
25
+ setAuthorizationConfig(destination: string, config: IAuthorizationConfig): Promise<void>;
26
+ setConnectionConfig(destination: string, config: IConnectionConfig): Promise<void>;
27
+ saveSession(destination: string, config: IConfig | unknown): Promise<void>;
28
+ deleteSession(destination: string): Promise<void>;
29
+ /** The URL the session holds, or the target URL for a session not yet written. */
30
+ private ownServiceUrl;
31
+ }
32
+ //# sourceMappingURL=targetUrlSessionStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"targetUrlSessionStore.d.ts","sourceRoot":"","sources":["../../src/proxy/targetUrlSessionStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EACV,oBAAoB,EACpB,OAAO,EACP,iBAAiB,EACjB,aAAa,EACd,MAAM,mCAAmC,CAAC;AAE3C,qBAAa,qBAAsB,YAAW,aAAa;IAEvD,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,KAAK,EAAE,aAAa,EACpB,SAAS,EAAE,MAAM;IAG9B,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAKzD,mBAAmB,CACvB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAKpC,sBAAsB,CACpB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAIvC,sBAAsB,CACpB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC;IAIV,mBAAmB,CACvB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,IAAI,CAAC;IAOV,WAAW,CACf,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,OAAO,GAAG,OAAO,GACxB,OAAO,CAAC,IAAI,CAAC;IAOV,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvD,kFAAkF;YACpE,aAAa;CAI5B"}
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ /**
3
+ * A session store that tells the broker the proxy's `targetUrl` without
4
+ * writing it over the destination's own URL.
5
+ *
6
+ * auth-broker 3 refuses a destination whose session and service key both lack
7
+ * a `serviceUrl`, and an XSUAA service key for a BTP-hosted MCP server usually
8
+ * carries none: the target URL is what the proxy is told instead. The proxy
9
+ * used to write it into the session before building the broker — and, with
10
+ * `unsafe`, that session is the file `mcp-auth` writes too, so its `SAP_URL`
11
+ * was replaced by the MCP server's URL.
12
+ *
13
+ * Reads answer the target URL as the `serviceUrl`. Writes keep the URL the
14
+ * session already holds; only a session that does not exist yet is created
15
+ * with the target URL, since a store cannot create one without a URL.
16
+ * Everything else passes through to the store it wraps.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.TargetUrlSessionStore = void 0;
20
+ class TargetUrlSessionStore {
21
+ inner;
22
+ targetUrl;
23
+ constructor(inner, targetUrl) {
24
+ this.inner = inner;
25
+ this.targetUrl = targetUrl;
26
+ }
27
+ async loadSession(destination) {
28
+ const session = await this.inner.loadSession(destination);
29
+ return session ? { ...session, serviceUrl: this.targetUrl } : null;
30
+ }
31
+ async getConnectionConfig(destination) {
32
+ const connection = await this.inner.getConnectionConfig(destination);
33
+ return { ...(connection ?? {}), serviceUrl: this.targetUrl };
34
+ }
35
+ getAuthorizationConfig(destination) {
36
+ return this.inner.getAuthorizationConfig(destination);
37
+ }
38
+ setAuthorizationConfig(destination, config) {
39
+ return this.inner.setAuthorizationConfig(destination, config);
40
+ }
41
+ async setConnectionConfig(destination, config) {
42
+ await this.inner.setConnectionConfig(destination, {
43
+ ...config,
44
+ serviceUrl: await this.ownServiceUrl(destination),
45
+ });
46
+ }
47
+ async saveSession(destination, config) {
48
+ await this.inner.saveSession(destination, {
49
+ ...config,
50
+ serviceUrl: await this.ownServiceUrl(destination),
51
+ });
52
+ }
53
+ async deleteSession(destination) {
54
+ await this.inner.deleteSession?.(destination);
55
+ }
56
+ /** The URL the session holds, or the target URL for a session not yet written. */
57
+ async ownServiceUrl(destination) {
58
+ const stored = await this.inner.getConnectionConfig(destination);
59
+ return stored?.serviceUrl ?? this.targetUrl;
60
+ }
61
+ }
62
+ exports.TargetUrlSessionStore = TargetUrlSessionStore;
@@ -226,7 +226,13 @@ from `mcp-abap-adt-proxy` in exactly two ways, both deliberate:
226
226
  - **The port in the config is ignored.** A free one is bound instead and
227
227
  `proxy_start` reports the URL it got. Four of eight configs in practice declare
228
228
  `httpPort: 3001`, so honouring it is what made running two of them impossible.
229
- - **`httpHost` is ignored too**; the listener always binds `127.0.0.1`.
229
+ - **`httpHost` is honoured**, and `proxy_start` takes a `host` of its own that
230
+ wins over it. The default when neither says anything is `127.0.0.1`.
231
+
232
+ That default is a default, not a boundary: anything with a shell can forward a
233
+ port, so refusing to bind elsewhere only stopped the honest case. What it still
234
+ does is keep a proxy nobody asked to publish from becoming reachable without
235
+ someone saying so.
230
236
 
231
237
  Everything else — destination, `targetUrl`, `defaultHeaders`, `browser`,
232
238
  `browserAuthPort`, timeouts, `envFile` interpolation — comes from the config as
@@ -171,16 +171,30 @@ ping cloud-llm-hub.example.com
171
171
  better; each attempt logs `RETRY_ATTEMPT`, and the final failure logs
172
172
  `CREDENTIAL_HEADER_ERROR` with the reason.
173
173
 
174
- #### Error: "Failed to get JWT token from auth-broker"
174
+ #### Error: "Service key file not found for destination …" (or another token failure)
175
175
 
176
176
  **Symptoms:**
177
- - Proxy requests fail
178
- - Error about JWT token
177
+ - Proxy requests fail, or the standalone proxy exits at startup
178
+ - `CREDENTIAL_HEADER_ERROR` in the log
179
179
 
180
180
  **Causes:**
181
- - Service key not found
182
- - Invalid service key
183
- - AuthBroker configuration issue
181
+ - Service key not found. The proxy checks for it itself before asking the
182
+ auth-broker, and says so in these words:
183
+ ```
184
+ Service key file not found for destination "sk".
185
+ Please create service key file: sk.json
186
+ Searched in:
187
+ - /home/you/.config/mcp-abap-adt/service-keys
188
+ ```
189
+ - Invalid or incomplete service key — auth-providers' `ValidationError`, naming
190
+ the missing fields
191
+ - A browser login that did not complete — auth-providers' `BrowserAuthError`
192
+ (timeout, the identity provider's refusal, a busy callback port, a browser
193
+ that would not open)
194
+
195
+ Since the move to auth-broker 3, the provider's own error reaches the log as
196
+ it was raised; the broker no longer rewraps it into
197
+ `Token provider … error for <destination>`.
184
198
 
185
199
  **Solutions:**
186
200
 
@@ -215,9 +229,9 @@ export AUTH_BROKER_PATH="/custom/path"
215
229
  # Sessions will be resolved from /custom/path/sessions
216
230
  ```
217
231
 
218
- 4. **Test authentication manually:**
232
+ 4. **Test authentication manually** — through the proxy's own path:
219
233
  ```bash
220
- npx sap-abap-auth auth -k sk.json
234
+ npm run test-destination -- sk
221
235
  ```
222
236
 
223
237
  ### 3. Routing Issues
@@ -359,16 +373,16 @@ export LOG_LEVEL=debug
359
373
 
360
374
  **Solutions:**
361
375
 
362
- 1. **Token refresh is automatic:**
363
- - Proxy detects token expiration
364
- - Automatically refreshes token
365
- - Retries request with new token
376
+ 1. **Token renewal is the credential's, not the proxy's:**
377
+ - The proxy keeps no token cache; it asks the destination's credential for
378
+ a header on every request
379
+ - The auth-broker's provider renews an expired token with the refresh token,
380
+ or runs the browser login when there is none or it is refused
381
+ - A 401 from the target is passed back to the client; the proxy does not
382
+ retry the request
366
383
 
367
- 2. **Check token cache:**
368
- ```bash
369
- # Tokens are cached for 30 minutes
370
- # On 401/403 the proxy automatically clears the cache and retries with a fresh token
371
- ```
384
+ 2. **With `--unsafe`**, the session file holds the service URL, the token and
385
+ the refresh token — not the client secret, which stays in the service key.
372
386
 
373
387
  3. **Verify service key:**
374
388
  ```bash
@@ -438,8 +452,8 @@ Look for logs like:
438
452
 
439
453
  Look for logs like:
440
454
  ```
441
- [DEBUG] Retrieved JWT token from auth-broker
442
- [DEBUG] Using cached JWT token
455
+ [DEBUG] Asking the credential for a header
456
+ [INFO] [AuthBroker] Token saved for sk
443
457
  ```
444
458
 
445
459
  ## Getting Help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-abap-adt/proxy",
3
- "version": "4.1.1",
3
+ "version": "5.0.0",
4
4
  "description": "MCP proxy server for SAP ABAP ADT - proxies local requests to cloud-llm-hub with JWT authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -63,13 +63,13 @@
63
63
  "typescript": "^6.0.3"
64
64
  },
65
65
  "dependencies": {
66
- "@mcp-abap-adt/auth-broker": "^2.2.0",
67
- "@mcp-abap-adt/auth-providers": "^2.2.2",
68
- "@mcp-abap-adt/auth-stores": "^1.2.0",
69
- "@mcp-abap-adt/connection": "^9.2.1",
70
- "@mcp-abap-adt/header-validator": "^0.3.0",
71
- "@mcp-abap-adt/interfaces-auth": "^1.2.0",
72
- "@mcp-abap-adt/interfaces-auth-sap": "^1.0.0",
66
+ "@mcp-abap-adt/auth-broker": "^3.0.0",
67
+ "@mcp-abap-adt/auth-providers": "^4.2.0",
68
+ "@mcp-abap-adt/auth-stores": "^1.2.3",
69
+ "@mcp-abap-adt/connection": "^9.3.0",
70
+ "@mcp-abap-adt/header-validator": "^0.3.1",
71
+ "@mcp-abap-adt/interfaces-auth": "^2.1.0",
72
+ "@mcp-abap-adt/interfaces-auth-sap": "^1.0.1",
73
73
  "@mcp-abap-adt/interfaces-network": "^2.0.0",
74
74
  "@mcp-abap-adt/logger": "^0.4.0",
75
75
  "@modelcontextprotocol/sdk": "^1.26.0",
@@ -79,7 +79,7 @@
79
79
  "zod": "^4.3.6"
80
80
  },
81
81
  "engines": {
82
- "node": ">=18.2.0",
82
+ "node": "^22 || ^24",
83
83
  "npm": ">=9.0.0"
84
84
  },
85
85
  "files": [