@mcp-abap-adt/proxy 4.0.0 → 4.1.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,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.1.0] - 2026-09-24
11
+
12
+ **The management mode can now say which credentials are available, and start a
13
+ proxy with one.** A config says which service to reach; an environment says what
14
+ to authenticate with. Both are chosen by name, so a client picks them without
15
+ being told any paths.
16
+
17
+ ### Added
18
+
19
+ - **`proxy_environments`** — lists the environments in `sessions/`, one SAP
20
+ system's credentials each, by the name `proxy_start` takes. Variable **names**
21
+ only, never values: this is read to help a client choose, and a value here
22
+ would be a credential in a tool result. `*.env.template` and anything that is
23
+ not a `.env` are left out.
24
+
25
+ - **`environment` on `proxy_start`** — optional, and the reason it exists: a
26
+ config that references `${SAP_LOGIN}` without naming an `envFile:` of its own
27
+ had nowhere to get it from, so `proxy_start` failed on exactly the configs a
28
+ user runs with `--env-file` on the command line. A name, not a path, for the
29
+ same reason `config` is a name: `sessions/` sits beside `proxy/` and
30
+ `service-keys/`.
31
+
32
+ One `--env-file` on the management process would not have served: it overrides
33
+ every config's own `envFile:`, so one system's secrets would reach every other
34
+ system started in that session. Per call, it cannot.
35
+
36
+ - `loadConfig(configPath, envFileOverride)` carries it. Precedence, most
37
+ specific first: this argument, then `--env-file`, then the config's own
38
+ `envFile:`.
39
+
40
+ ### Unchanged, and deliberately
41
+
42
+ The management mode speaks **stdio only** — no `--transport`, and it listens on
43
+ no port of its own. That is the whole point: the client runs it as a child
44
+ process, and when the client goes the stdio closes and every proxy it started
45
+ goes with it. Over HTTP it would outlive its client and leave ports and tokens
46
+ held, which would be worse than starting proxies by hand.
47
+
48
+ ## [4.0.1] - 2026-09-24
49
+
50
+ **A variable from an env file could be silently lost, and the failure blamed the
51
+ variable instead of naming the file.** Both hit Windows hardest, with the same
52
+ config and the same `.env` that work on Linux.
53
+
54
+ ### Fixed
55
+
56
+ - **An empty environment variable no longer shadows the env file.** The lookup
57
+ was `process.env[key] ?? envFileMap[key]`, and `??` skips only `undefined` — so
58
+ a `SAP_LOGIN=` sitting in the environment beat the file the user had pointed at
59
+ with `--env-file`, and the header went out empty with nothing reported. Windows
60
+ carries variables nobody set deliberately, which is why the same files behaved
61
+ differently there. A real value in the environment still wins.
62
+
63
+ - **The interpolation failure now names where it looked.** It said only
64
+ `Config references undefined env variable: SAP_LOGIN`, so three different
65
+ causes produced one sentence and the cause had to be guessed:
66
+
67
+ ```
68
+ ... looked in process.env only — no env file was given (--env-file or envFile:)
69
+ ... looked in process.env, then /path/e19.env, which yielded NO variables
70
+ (0 bytes) — check its encoding, since a UTF-16 file reads as nothing here
71
+ ... looked in process.env, then /path/e19.env (1: SAP_PASSWORD)
72
+ ```
73
+
74
+ An env file that yields nothing is still not an error — an empty `.env` is
75
+ legitimate, and a config may take every value from the environment. It is
76
+ reported by whoever then needs a variable, where it can be said usefully.
77
+
78
+ ### Documentation
79
+
80
+ - `docs/YAML_CONFIG.md`: a Windows path in **double** quotes is a YAML escape
81
+ context. `envFile: "C:\Users\..."` fails to parse (`\U` is a Unicode escape)
82
+ and `"C:\temp\e19.env"` parses *silently wrong* (`\e` becomes 0x1B). Use
83
+ single quotes, no quotes, or forward slashes.
84
+
10
85
  ## [4.0.0] - 2026-09-24
11
86
 
12
87
  **The proxy stops re-implementing the broker, there is one forwarding path
package/README.md CHANGED
@@ -76,7 +76,8 @@ than travelling through a tool call.
76
76
  | Tool | What it does |
77
77
  |---|---|
78
78
  | `proxy_configs` | Lists the configs available, by the name `proxy_start` takes. Call it first — the names cannot be guessed. |
79
- | `proxy_start` | Starts a proxy from one of those configs and returns the URL it bound. The **port is not taken from the config**: a free one is bound instead. |
79
+ | `proxy_environments` | Lists the environments available — one SAP system's credentials each, by the name `proxy_start` takes. Variable NAMES only, never values. |
80
+ | `proxy_start` | Starts a proxy from a config and, when the config needs one, an environment. Returns the URL it bound. The **port is not taken from the config**: a free one is bound instead. |
80
81
  | `proxy_stop` | Stops a proxy this session started, freeing its port and releasing its credential. Proxies started by other sessions are never touched. |
81
82
  | `proxy_status` | Lists this session's proxies and any others on this machine. Records whose process has died are pruned when read, so it cannot report a ghost. |
82
83
 
@@ -37,7 +37,11 @@ Tools offered to the client:
37
37
  proxy_configs List the proxy configs on this machine, by the name
38
38
  proxy_start takes. Call this first — the names cannot be
39
39
  guessed.
40
- proxy_start Start a proxy from one of those configs. The config supplies
40
+ proxy_environments
41
+ List the environments — one SAP system's credentials each,
42
+ from sessions/. Variable NAMES only, never values.
43
+ proxy_start Start a proxy from a config plus, when the config needs one,
44
+ an environment. The config supplies
41
45
  the destination, target URL, default headers and timeouts;
42
46
  the PORT does not come from it — a free one is bound, so
43
47
  several proxies can run at once, and the URL returned is the
@@ -27,7 +27,18 @@ export interface ProxyConfig {
27
27
  * - If --config is provided, load ONLY from that file
28
28
  * - If --config is NOT provided, load ONLY from command line parameters and environment variables
29
29
  */
30
- export declare function loadConfig(configPath?: string): ProxyConfig;
30
+ export declare function loadConfig(configPath?: string,
31
+ /**
32
+ * The env file for THIS load, ahead of `--env-file` and the config's own
33
+ * `envFile:`.
34
+ *
35
+ * The MCP mode starts a proxy from a config plus an ENVIRONMENT chosen by
36
+ * name, and has no command line of its own to put `--env-file` on. One flag on
37
+ * the management process would not serve either: it overrides every config's
38
+ * own `envFile:`, so one system's secrets would reach every other system
39
+ * started in that session.
40
+ */
41
+ envFileOverride?: string): ProxyConfig;
31
42
  /**
32
43
  * Transport-related fields from config file (not part of ProxyConfig but needed by transportConfig)
33
44
  */
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IAEjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGxC,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAG/B,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,WAAW,CAoC3D;AAwED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,mBAAmB,CAQrB;AAkCD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAYhC;AAmJD;;GAEG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,SAAS,CAElD;AAUD;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG;IACnD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAoDA"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IAEjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGxC,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAG/B,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,UAAU,CAAC,EAAE,MAAM;AACnB;;;;;;;;;GASG;AACH,eAAe,CAAC,EAAE,MAAM,GACvB,WAAW,CAkDb;AAwED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,mBAAmB,CAQrB;AAsCD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAYhC;AAmJD;;GAEG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,SAAS,CAElD;AAUD;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG;IACnD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAoDA"}
@@ -53,7 +53,18 @@ const envInterpolation_js_1 = require("./envInterpolation.js");
53
53
  * - If --config is provided, load ONLY from that file
54
54
  * - If --config is NOT provided, load ONLY from command line parameters and environment variables
55
55
  */
56
- function loadConfig(configPath) {
56
+ function loadConfig(configPath,
57
+ /**
58
+ * The env file for THIS load, ahead of `--env-file` and the config's own
59
+ * `envFile:`.
60
+ *
61
+ * The MCP mode starts a proxy from a config plus an ENVIRONMENT chosen by
62
+ * name, and has no command line of its own to put `--env-file` on. One flag on
63
+ * the management process would not serve either: it overrides every config's
64
+ * own `envFile:`, so one system's secrets would reach every other system
65
+ * started in that session.
66
+ */
67
+ envFileOverride) {
57
68
  // Get config path from parameter or command line (--config/-c)
58
69
  // Do NOT use environment variable MCP_PROXY_CONFIG - only explicit --config parameter
59
70
  const finalConfigPath = configPath || getConfigPath();
@@ -65,10 +76,18 @@ function loadConfig(configPath) {
65
76
  throw new Error(`Config file not found: ${finalConfigPath}`);
66
77
  }
67
78
  const fileConfig = loadConfigFile(finalConfigPath);
68
- const envFilePath = resolveEnvFilePath(fileConfig, finalConfigPath);
79
+ const envFilePath = resolveEnvFilePath(fileConfig, finalConfigPath, envFileOverride);
69
80
  const envFileMap = envFilePath ? (0, envInterpolation_js_1.loadEnvFile)(envFilePath) : {};
70
81
  const lookup = (0, envInterpolation_js_1.buildLookup)(envFileMap);
71
- const interpolated = (0, envInterpolation_js_1.interpolateConfig)(fileConfig, lookup);
82
+ const keys = Object.keys(envFileMap);
83
+ const sources = !envFilePath
84
+ ? 'process.env only — no env file was given (--env-file or envFile:)'
85
+ : keys.length === 0
86
+ ? `process.env, then ${envFilePath}, which yielded NO variables ` +
87
+ `(${fs.statSync(envFilePath).size} bytes) — check its encoding, ` +
88
+ `since a UTF-16 file reads as nothing here, and that lines look like KEY=value`
89
+ : `process.env, then ${envFilePath} (${keys.length}: ${keys.join(', ')})`;
90
+ const interpolated = (0, envInterpolation_js_1.interpolateConfig)(fileConfig, lookup, '', sources);
72
91
  delete interpolated.envFile;
73
92
  const base = applyDefaults(interpolated);
74
93
  const cli = readCliOverrides();
@@ -166,7 +185,11 @@ function loadConfigFile(filePath) {
166
185
  * cwd); otherwise the YAML `envFile` field, resolved relative to the config
167
186
  * file's directory. Returns undefined when neither is set.
168
187
  */
169
- function resolveEnvFilePath(rawConfig, configPath) {
188
+ function resolveEnvFilePath(rawConfig, configPath, override) {
189
+ // Most specific first: this call, then the command line, then what the config
190
+ // says about itself.
191
+ if (override !== undefined)
192
+ return path.resolve(override);
170
193
  const cliEnvFile = getArgValue('--env-file');
171
194
  if (cliEnvFile !== undefined)
172
195
  return path.resolve(cliEnvFile);
@@ -8,14 +8,16 @@
8
8
  * default when one is given. A ${VAR} without a default that resolves to
9
9
  * undefined throws, naming the variable and the field it came from.
10
10
  */
11
- export declare function interpolateString(input: string, lookup: (key: string) => string | undefined, fieldPath: string): string;
11
+ export declare function interpolateString(input: string, lookup: (key: string) => string | undefined, fieldPath: string,
12
+ /** Where values were looked for, named in the failure. */
13
+ sources?: string): string;
12
14
  /**
13
15
  * Recursively interpolate all string values in a parsed config object.
14
16
  * Objects/arrays are walked; non-string scalars are returned unchanged.
15
17
  * The field path (e.g. `defaultHeaders.x-sap-password`) is threaded through for
16
18
  * error messages.
17
19
  */
18
- export declare function interpolateConfig(value: unknown, lookup: (key: string) => string | undefined, path?: string): unknown;
20
+ export declare function interpolateConfig(value: unknown, lookup: (key: string) => string | undefined, path?: string, sources?: string): unknown;
19
21
  /**
20
22
  * Parse a .env file into a flat map. Throws if the path is given but missing —
21
23
  * a specified-yet-absent secret source is a configuration error, not a no-op.
@@ -23,6 +25,16 @@ export declare function interpolateConfig(value: unknown, lookup: (key: string)
23
25
  export declare function loadEnvFile(envFilePath: string): Record<string, string>;
24
26
  /**
25
27
  * Build a lookup over process.env (highest priority) then the parsed .env map.
28
+ *
29
+ * An EMPTY environment variable does not count as a value. It used to: `??`
30
+ * skips only `undefined`, so a `SAP_LOGIN=` sitting in the environment won
31
+ * against the env file the user had explicitly pointed at with `--env-file`,
32
+ * and the header went out empty with nothing reported. Windows carries
33
+ * variables nobody set deliberately, which is why the same config and the same
34
+ * `.env` behaved differently there.
35
+ *
36
+ * A real value in the environment still wins — that is the documented
37
+ * precedence, and it is how a one-off override is meant to work.
26
38
  */
27
39
  export declare function buildLookup(envFileMap: Record<string, string>): (key: string) => string | undefined;
28
40
  //# sourceMappingURL=envInterpolation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"envInterpolation.d.ts","sourceRoot":"","sources":["../../src/lib/envInterpolation.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,SAAS,EAAE,MAAM,GAChB,MAAM,CAiBR;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,IAAI,SAAK,GACR,OAAO,CAiBT;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAKvE;AAED;;GAEG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAGrC"}
1
+ {"version":3,"file":"envInterpolation.d.ts","sourceRoot":"","sources":["../../src/lib/envInterpolation.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,SAAS,EAAE,MAAM;AACjB,0DAA0D;AAC1D,OAAO,SAAgB,GACtB,MAAM,CAsBR;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,IAAI,SAAK,EACT,OAAO,SAAgB,GACtB,OAAO,CAsBT;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASvE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAQrC"}
@@ -50,7 +50,9 @@ const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
50
50
  * default when one is given. A ${VAR} without a default that resolves to
51
51
  * undefined throws, naming the variable and the field it came from.
52
52
  */
53
- function interpolateString(input, lookup, fieldPath) {
53
+ function interpolateString(input, lookup, fieldPath,
54
+ /** Where values were looked for, named in the failure. */
55
+ sources = 'process.env') {
54
56
  return input.replace(PLACEHOLDER, (_match, name, defaultVal) => {
55
57
  const value = lookup(name);
56
58
  const isEmpty = value === undefined || value === '';
@@ -58,7 +60,12 @@ function interpolateString(input, lookup, fieldPath) {
58
60
  return isEmpty ? defaultVal : value;
59
61
  }
60
62
  if (value === undefined) {
61
- throw new Error(`Config references undefined env variable: ${name} (referenced in ${fieldPath})`);
63
+ // Naming where it looked, because the old message named only the
64
+ // variable — so a file never read, a file read empty, and a value
65
+ // shadowed by the environment all produced the same sentence and the
66
+ // cause had to be guessed.
67
+ throw new Error(`Config references undefined env variable: ${name} ` +
68
+ `(referenced in ${fieldPath}; looked in ${sources})`);
62
69
  }
63
70
  return value;
64
71
  });
@@ -69,17 +76,17 @@ function interpolateString(input, lookup, fieldPath) {
69
76
  * The field path (e.g. `defaultHeaders.x-sap-password`) is threaded through for
70
77
  * error messages.
71
78
  */
72
- function interpolateConfig(value, lookup, path = '') {
79
+ function interpolateConfig(value, lookup, path = '', sources = 'process.env') {
73
80
  if (typeof value === 'string') {
74
- return interpolateString(value, lookup, path || '(root)');
81
+ return interpolateString(value, lookup, path || '(root)', sources);
75
82
  }
76
83
  if (Array.isArray(value)) {
77
- return value.map((item, i) => interpolateConfig(item, lookup, `${path}[${i}]`));
84
+ return value.map((item, i) => interpolateConfig(item, lookup, `${path}[${i}]`, sources));
78
85
  }
79
86
  if (value !== null && typeof value === 'object') {
80
87
  const out = {};
81
88
  for (const [key, val] of Object.entries(value)) {
82
- out[key] = interpolateConfig(val, lookup, path ? `${path}.${key}` : key);
89
+ out[key] = interpolateConfig(val, lookup, path ? `${path}.${key}` : key, sources);
83
90
  }
84
91
  return out;
85
92
  }
@@ -93,11 +100,31 @@ function loadEnvFile(envFilePath) {
93
100
  if (!fs.existsSync(envFilePath)) {
94
101
  throw new Error(`env file not found: ${envFilePath}`);
95
102
  }
103
+ // Not an error when it yields nothing: an empty `.env` is legitimate, and a
104
+ // config may take every value from the environment. But the emptiness is
105
+ // reported by whoever needs a variable — see the `sources` note threaded into
106
+ // the interpolation failure, which names the file, its size and its key count.
96
107
  return dotenv.parse(fs.readFileSync(envFilePath, 'utf-8'));
97
108
  }
98
109
  /**
99
110
  * Build a lookup over process.env (highest priority) then the parsed .env map.
111
+ *
112
+ * An EMPTY environment variable does not count as a value. It used to: `??`
113
+ * skips only `undefined`, so a `SAP_LOGIN=` sitting in the environment won
114
+ * against the env file the user had explicitly pointed at with `--env-file`,
115
+ * and the header went out empty with nothing reported. Windows carries
116
+ * variables nobody set deliberately, which is why the same config and the same
117
+ * `.env` behaved differently there.
118
+ *
119
+ * A real value in the environment still wins — that is the documented
120
+ * precedence, and it is how a one-off override is meant to work.
100
121
  */
101
122
  function buildLookup(envFileMap) {
102
- return (key) => process.env[key] ?? envFileMap[key];
123
+ return (key) => {
124
+ const fromEnvironment = process.env[key];
125
+ if (fromEnvironment !== undefined && fromEnvironment !== '') {
126
+ return fromEnvironment;
127
+ }
128
+ return envFileMap[key];
129
+ };
103
130
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * One environment: a `.env` beside the configs, holding one SAP system's
3
+ * credentials.
4
+ *
5
+ * A config names the service to reach; an environment names the credentials to
6
+ * reach it with. They are separate because several configs commonly point at one
7
+ * system — four of the configs in practice all want `e19` — and one config can
8
+ * be run against different systems.
9
+ */
10
+ export interface EnvironmentEntry {
11
+ /** The file name without its extension — what a client asks for. */
12
+ name: string;
13
+ file: string;
14
+ /**
15
+ * Which variables it defines. Names only, never values: this is read to help
16
+ * a client choose, and a value here would be a credential in a tool result.
17
+ */
18
+ variables: string[];
19
+ }
20
+ /** Where the environments live, beside `proxy/` and `service-keys/`. */
21
+ export declare function environmentDir(): string;
22
+ /**
23
+ * The environments on disk.
24
+ *
25
+ * One that cannot be read is still listed, with no variables beside it — hiding
26
+ * it would make "no such environment" the answer for a file sitting right there.
27
+ */
28
+ export declare function listEnvironments(dir?: string): EnvironmentEntry[];
29
+ /**
30
+ * The file an environment name refers to.
31
+ *
32
+ * A name, never a path: `sessions/` sits beside `proxy/` and `service-keys/`,
33
+ * and the value comes from a language model.
34
+ */
35
+ export declare function resolveEnvironment(name: string, dir?: string): string;
36
+ //# sourceMappingURL=environments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"environments.d.ts","sourceRoot":"","sources":["../../src/mcp/environments.ts"],"names":[],"mappings":"AAKA;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,wEAAwE;AACxE,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,GAAE,MAAyB,GAC7B,gBAAgB,EAAE,CAgCpB;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,GAAG,GAAE,MAAyB,GAC7B,MAAM,CAmBR"}
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.environmentDir = environmentDir;
37
+ exports.listEnvironments = listEnvironments;
38
+ exports.resolveEnvironment = resolveEnvironment;
39
+ // src/mcp/environments.ts
40
+ const node_fs_1 = require("node:fs");
41
+ const path = __importStar(require("node:path"));
42
+ const stores_js_1 = require("../lib/stores.js");
43
+ /** Where the environments live, beside `proxy/` and `service-keys/`. */
44
+ function environmentDir() {
45
+ return (0, stores_js_1.storeDir)('sessions');
46
+ }
47
+ /**
48
+ * The environments on disk.
49
+ *
50
+ * One that cannot be read is still listed, with no variables beside it — hiding
51
+ * it would make "no such environment" the answer for a file sitting right there.
52
+ */
53
+ function listEnvironments(dir = environmentDir()) {
54
+ if (!(0, node_fs_1.existsSync)(dir))
55
+ return [];
56
+ const entries = [];
57
+ for (const name of (0, node_fs_1.readdirSync)(dir).sort()) {
58
+ // `.env` exactly: this leaves out `e19.env.template`, whose extension is
59
+ // `.template`, along with notes and anything else kept in the folder.
60
+ if (path.extname(name).toLowerCase() !== '.env')
61
+ continue;
62
+ const file = path.join(dir, name);
63
+ try {
64
+ if (!(0, node_fs_1.statSync)(file).isFile())
65
+ continue;
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ const entry = {
71
+ name: path.basename(name, '.env'),
72
+ file,
73
+ variables: [],
74
+ };
75
+ try {
76
+ for (const line of (0, node_fs_1.readFileSync)(file, 'utf-8').split(/\r?\n/)) {
77
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
78
+ if (match)
79
+ entry.variables.push(match[1]);
80
+ }
81
+ }
82
+ catch {
83
+ // Listed with no variables. Whoever starts a proxy with it gets the real
84
+ // failure, against a name the caller chose.
85
+ }
86
+ entries.push(entry);
87
+ }
88
+ return entries;
89
+ }
90
+ /**
91
+ * The file an environment name refers to.
92
+ *
93
+ * A name, never a path: `sessions/` sits beside `proxy/` and `service-keys/`,
94
+ * and the value comes from a language model.
95
+ */
96
+ function resolveEnvironment(name, dir = environmentDir()) {
97
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) {
98
+ throw new Error(`Invalid environment name "${name}": it must be a name from ${dir}, not a path.`);
99
+ }
100
+ const available = listEnvironments(dir);
101
+ const found = available.find((entry) => entry.name === name || path.basename(entry.file) === name);
102
+ if (found)
103
+ return found.file;
104
+ const known = available.map((entry) => entry.name).join(', ');
105
+ throw new Error(known
106
+ ? `No environment named "${name}" in ${dir}. Available: ${known}`
107
+ : `No environments found in ${dir}.`);
108
+ }
@@ -24,5 +24,5 @@ export interface ProxyTool {
24
24
  inputSchema: z.ZodRawShape;
25
25
  handler: (args: Record<string, unknown>) => Promise<ToolResult>;
26
26
  }
27
- export declare function createProxyTools(supervisor: ProxySupervisor, configDir?: string): ProxyTool[];
27
+ export declare function createProxyTools(supervisor: ProxySupervisor, configDir?: string, envDir?: string): ProxyTool[];
28
28
  //# sourceMappingURL=tools.d.ts.map
@@ -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;AAQxB,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,GACnC,SAAS,EAAE,CA2Jb"}
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"}
package/dist/mcp/tools.js CHANGED
@@ -6,6 +6,7 @@ exports.createProxyTools = createProxyTools;
6
6
  const zod_1 = require("zod");
7
7
  const config_js_1 = require("../lib/config.js");
8
8
  const configs_js_1 = require("./configs.js");
9
+ const environments_js_1 = require("./environments.js");
9
10
  const supervisor_js_1 = require("./supervisor.js");
10
11
  /**
11
12
  * The thing the client has to keep being told.
@@ -23,8 +24,30 @@ exports.SHUTDOWN_REMINDER = 'When you are finished with this proxy, call proxy_s
23
24
  const text = (body) => ({
24
25
  content: [{ type: 'text', text: body }],
25
26
  });
26
- function createProxyTools(supervisor, configDir = (0, configs_js_1.proxyConfigDir)()) {
27
+ function createProxyTools(supervisor, configDir = (0, configs_js_1.proxyConfigDir)(), envDir = (0, environments_js_1.environmentDir)()) {
27
28
  return [
29
+ {
30
+ name: 'proxy_environments',
31
+ title: 'List the environments available',
32
+ description: 'List the environments on this machine, by the name proxy_start takes. ' +
33
+ 'An environment is one SAP system\u2019s credentials; a config says which ' +
34
+ 'service to reach, an environment says what to authenticate with. ' +
35
+ 'Several configs commonly want the same environment. Variable NAMES are ' +
36
+ 'shown, never their values.',
37
+ inputSchema: {},
38
+ handler: async () => {
39
+ const environments = (0, environments_js_1.listEnvironments)(envDir);
40
+ if (environments.length === 0) {
41
+ return text(`No environments found in ${envDir}.`);
42
+ }
43
+ return text([
44
+ `Environments in ${envDir}:`,
45
+ ...environments.map((e) => ` ${e.name}${e.variables.length ? ` — ${e.variables.join(', ')}` : ' — (none readable)'}`),
46
+ '',
47
+ 'Pass one to proxy_start as "environment" when the config uses ${VAR}.',
48
+ ].join('\n'));
49
+ },
50
+ },
28
51
  {
29
52
  name: 'proxy_configs',
30
53
  title: 'List the proxy configs available',
@@ -56,7 +79,7 @@ function createProxyTools(supervisor, configDir = (0, configs_js_1.proxyConfigDi
56
79
  {
57
80
  name: 'proxy_start',
58
81
  title: 'Start an authenticating proxy',
59
- description: 'Start a local proxy from one of the configs proxy_configs lists. The ' +
82
+ description: 'Start a local proxy from a config and, when the config needs one, an environment. The ' +
60
83
  'config supplies the destination, target URL, default headers and ' +
61
84
  'timeouts — including any credentials, which stay in the config and ' +
62
85
  'are never passed through here. The port is NOT taken from the config: ' +
@@ -66,6 +89,10 @@ function createProxyTools(supervisor, configDir = (0, configs_js_1.proxyConfigDi
66
89
  config: zod_1.z
67
90
  .string()
68
91
  .describe('Name of a proxy config, as proxy_configs lists it (for example "nvcr_d24").'),
92
+ environment: zod_1.z
93
+ .string()
94
+ .optional()
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.'),
69
96
  idleTimeoutMs: zod_1.z
70
97
  .number()
71
98
  .optional()
@@ -74,14 +101,19 @@ function createProxyTools(supervisor, configDir = (0, configs_js_1.proxyConfigDi
74
101
  handler: async (args) => {
75
102
  const name = String(args.config);
76
103
  const file = (0, configs_js_1.resolveProxyConfig)(name, configDir);
104
+ const environment = args.environment;
105
+ const envFile = environment
106
+ ? (0, environments_js_1.resolveEnvironment)(environment, envDir)
107
+ : undefined;
77
108
  const started = await supervisor.start({
78
109
  name,
79
- config: (0, config_js_1.loadConfig)(file),
110
+ config: (0, config_js_1.loadConfig)(file, envFile),
80
111
  idleTimeoutMs: args.idleTimeoutMs,
81
112
  });
82
113
  return text([
83
114
  `Proxy running at ${started.url}`,
84
115
  ` config: ${started.name}`,
116
+ ` environment: ${environment ?? "(the config's own, or the environment)"}`,
85
117
  ` instanceId: ${started.instanceId}`,
86
118
  ` destination: ${started.destination}`,
87
119
  '',
@@ -243,7 +243,8 @@ It works from the configs in `~/.config/mcp-abap-adt/proxy/` — the same files
243
243
  | Tool | |
244
244
  |---|---|
245
245
  | `proxy_configs` | the names available; call it first, they cannot be guessed |
246
- | `proxy_start` | starts one on a **free port** and returns the URL bound |
246
+ | `proxy_environments` | the environments available — one SAP system's credentials each. Variable names only, never values |
247
+ | `proxy_start` | starts one on a **free port** and returns the URL bound. Takes `environment` when the config uses `${VAR}` and names no `envFile` |
247
248
  | `proxy_stop` | frees the port and releases the credential; never touches another session's proxy |
248
249
  | `proxy_status` | this session's proxies and everyone else's, dead records pruned on read |
249
250
 
package/docs/USAGE.md CHANGED
@@ -155,7 +155,8 @@ It works from the configs in `~/.config/mcp-abap-adt/proxy/` — the same files
155
155
  | Tool | |
156
156
  |---|---|
157
157
  | `proxy_configs` | the names available; call it first, they cannot be guessed |
158
- | `proxy_start` | starts one on a **free port** and returns the URL bound |
158
+ | `proxy_environments` | the environments available — one SAP system's credentials each. Variable names only, never values |
159
+ | `proxy_start` | starts one on a **free port** and returns the URL bound. Takes `environment` when the config uses `${VAR}` and names no `envFile` |
159
160
  | `proxy_stop` | frees the port and releases the credential; never touches another session's proxy |
160
161
  | `proxy_status` | this session's proxies and everyone else's, dead records pruned on read |
161
162
 
@@ -206,6 +206,23 @@ requestTimeout: 120000
206
206
  | `maxRetries` | `number` | `3` | Maximum number of retry attempts |
207
207
  | `retryDelay` | `number` | `1000` | Delay between retries (milliseconds) |
208
208
  | `requestTimeout` | `number` | `60000` | Request timeout (milliseconds) |
209
+ ### Windows paths must not go in double quotes
210
+
211
+ In YAML, a double-quoted string is an **escape context**, and a Windows path is
212
+ full of backslashes. Measured with the parser this package uses:
213
+
214
+ | written as | result |
215
+ |---|---|
216
+ | `envFile: "C:\Users\me\e19.env"` | **fails** — `expected hexadecimal character`, because `\U` starts a Unicode escape |
217
+ | `envFile: "C:\temp\e19.env"` | **silently wrong** — `\e` becomes the escape character `0x1B`, so the path points nowhere |
218
+ | `envFile: 'C:\Users\me\e19.env'` | correct |
219
+ | `envFile: C:\Users\me\e19.env` | correct |
220
+ | `envFile: "C:/Users/me/e19.env"` | correct |
221
+
222
+ The second row is the dangerous one: it does not fail, it resolves to a path that
223
+ does not exist. Forward slashes are the safest form — Node accepts them on
224
+ Windows, and they carry no meaning inside quotes.
225
+
209
226
  | ~~`circuitBreakerThreshold`~~ | `number` | — | **No effect since 4.0.0.** Still accepted so existing files load; the circuit breaker guarded the buffered forward that release removed |
210
227
  | ~~`circuitBreakerTimeout`~~ | `number` | — | **No effect since 4.0.0.** As above |
211
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-abap-adt/proxy",
3
- "version": "4.0.0",
3
+ "version": "4.1.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",