@mcp-abap-adt/proxy 2.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/LICENSE +669 -17
  3. package/README.md +79 -8
  4. package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
  5. package/dist/index.d.ts +10 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +54 -97
  8. package/dist/lib/config.d.ts.map +1 -1
  9. package/dist/lib/config.js +9 -1
  10. package/dist/lib/envInterpolation.d.ts +14 -2
  11. package/dist/lib/envInterpolation.d.ts.map +1 -1
  12. package/dist/lib/envInterpolation.js +34 -7
  13. package/dist/lib/stores.d.ts +23 -2
  14. package/dist/lib/stores.d.ts.map +1 -1
  15. package/dist/lib/stores.js +56 -1
  16. package/dist/mcp/cli.d.ts +2 -0
  17. package/dist/mcp/cli.d.ts.map +1 -0
  18. package/dist/mcp/cli.js +21 -0
  19. package/dist/mcp/configs.d.ts +33 -0
  20. package/dist/mcp/configs.d.ts.map +1 -0
  21. package/dist/mcp/configs.js +142 -0
  22. package/dist/mcp/ports.d.ts +15 -0
  23. package/dist/mcp/ports.d.ts.map +1 -0
  24. package/dist/mcp/ports.js +35 -0
  25. package/dist/mcp/registry.d.ts +57 -0
  26. package/dist/mcp/registry.d.ts.map +1 -0
  27. package/dist/mcp/registry.js +145 -0
  28. package/dist/mcp/server.d.ts +34 -0
  29. package/dist/mcp/server.d.ts.map +1 -0
  30. package/dist/mcp/server.js +79 -0
  31. package/dist/mcp/shutdown.d.ts +37 -0
  32. package/dist/mcp/shutdown.d.ts.map +1 -0
  33. package/dist/mcp/shutdown.js +82 -0
  34. package/dist/mcp/supervisor.d.ts +125 -0
  35. package/dist/mcp/supervisor.d.ts.map +1 -0
  36. package/dist/mcp/supervisor.js +330 -0
  37. package/dist/mcp/tools.d.ts +28 -0
  38. package/dist/mcp/tools.d.ts.map +1 -0
  39. package/dist/mcp/tools.js +152 -0
  40. package/dist/proxy/btpProxy.d.ts +24 -73
  41. package/dist/proxy/btpProxy.d.ts.map +1 -1
  42. package/dist/proxy/btpProxy.js +65 -616
  43. package/dist/proxy/credentials.d.ts +45 -0
  44. package/dist/proxy/credentials.d.ts.map +1 -0
  45. package/dist/proxy/credentials.js +41 -0
  46. package/dist/proxy/requestHandler.d.ts +38 -0
  47. package/dist/proxy/requestHandler.d.ts.map +1 -0
  48. package/dist/proxy/requestHandler.js +73 -0
  49. package/dist/proxy/reverseProxy.d.ts +11 -2
  50. package/dist/proxy/reverseProxy.d.ts.map +1 -1
  51. package/dist/proxy/reverseProxy.js +52 -9
  52. package/dist/router/headerAnalyzer.js +2 -2
  53. package/dist/router/requestInterceptor.js +9 -9
  54. package/docs/API.md +172 -0
  55. package/docs/ARCHITECTURE.md +322 -0
  56. package/docs/CLIENT_SETUP.md +413 -0
  57. package/docs/CONFIGURATION.md +258 -0
  58. package/docs/MIGRATION-4.0.md +125 -0
  59. package/docs/ROUTING_LOGIC.md +126 -0
  60. package/docs/TROUBLESHOOTING.md +488 -0
  61. package/docs/USAGE.md +422 -0
  62. package/docs/YAML_CONFIG.md +290 -0
  63. package/docs/mcp-proxy-config.example.yaml +62 -0
  64. package/package.json +17 -10
  65. package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
  66. package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
  67. package/dist/proxy/cloudLlmHubProxy.js +0 -3
@@ -14,10 +14,31 @@ import type { IServiceKeyStore, ISessionStore } from '@mcp-abap-adt/auth-broker'
14
14
  * - Windows: %USERPROFILE%\Documents\mcp-abap-adt\{subfolder}
15
15
  * 3. Current working directory (process.cwd())
16
16
  *
17
- * @param subfolder Subfolder name ('service-keys' or 'sessions')
17
+ * @param subfolder One of the four folders; see {@link StoreFolder}
18
18
  * @returns Array of resolved absolute paths
19
19
  */
20
- export declare function getPlatformPaths(subfolder?: 'service-keys' | 'sessions'): string[];
20
+ /**
21
+ * The four folders the toolchain keeps under one base.
22
+ *
23
+ * `service-keys/` and `sessions/` are the auth broker's. `proxy/` holds one
24
+ * ready config per proxy — the files `--config` takes. `runtime/` holds a
25
+ * record per live proxy, so one session can see another's.
26
+ */
27
+ export declare const STORE_FOLDERS: readonly ["service-keys", "sessions", "proxy", "runtime"];
28
+ export type StoreFolder = (typeof STORE_FOLDERS)[number];
29
+ /**
30
+ * THE base directory — one answer, not a search path.
31
+ *
32
+ * `AUTH_BROKER_PATH` relocates it, so all four folders move together; when it
33
+ * lists several, the first is the one written to. Unlike
34
+ * {@link getPlatformPaths}, this never falls back to the working directory: a
35
+ * file written beside wherever a client happened to be launched from is a file
36
+ * the next session will not find.
37
+ */
38
+ export declare function storeBaseDir(): string;
39
+ /** THE directory for one of the four folders. */
40
+ export declare function storeDir(folder: StoreFolder): string;
41
+ export declare function getPlatformPaths(subfolder?: StoreFolder): string[];
21
42
  /**
22
43
  * Get platform-specific stores
23
44
  * Returns XSUAA stores for BTP authentication:
@@ -1 +1 @@
1
- {"version":3,"file":"stores.d.ts","sourceRoot":"","sources":["../../src/lib/stores.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EACV,gBAAgB,EAChB,aAAa,EACd,MAAM,2BAA2B,CAAC;AAOnC;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,CAAC,EAAE,cAAc,GAAG,UAAU,GACtC,MAAM,EAAE,CAgEV;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC;IACxE,eAAe,EAAE,gBAAgB,CAAC;IAClC,YAAY,EAAE,aAAa,CAAC;CAC7B,CAAC,CAqBD"}
1
+ {"version":3,"file":"stores.d.ts","sourceRoot":"","sources":["../../src/lib/stores.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EACV,gBAAgB,EAChB,aAAa,EACd,MAAM,2BAA2B,CAAC;AAOnC;;;;;;;;;;;;GAYG;AACH;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,2DAKhB,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzD;;;;;;;;GAQG;AACH,wBAAgB,YAAY,IAAI,MAAM,CA0BrC;AAED,iDAAiD;AACjD,wBAAgB,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAEpD;AAED,wBAAgB,gBAAgB,CAAC,SAAS,CAAC,EAAE,WAAW,GAAG,MAAM,EAAE,CAgElE;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,GAAE,OAAe,GAAG,OAAO,CAAC;IACxE,eAAe,EAAE,gBAAgB,CAAC;IAClC,YAAY,EAAE,aAAa,CAAC;CAC7B,CAAC,CAqBD"}
@@ -38,6 +38,9 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.STORE_FOLDERS = void 0;
42
+ exports.storeBaseDir = storeBaseDir;
43
+ exports.storeDir = storeDir;
41
44
  exports.getPlatformPaths = getPlatformPaths;
42
45
  exports.getPlatformStores = getPlatformStores;
43
46
  const os = __importStar(require("node:os"));
@@ -53,9 +56,61 @@ const auth_stores_1 = require("@mcp-abap-adt/auth-stores");
53
56
  * - Windows: %USERPROFILE%\Documents\mcp-abap-adt\{subfolder}
54
57
  * 3. Current working directory (process.cwd())
55
58
  *
56
- * @param subfolder Subfolder name ('service-keys' or 'sessions')
59
+ * @param subfolder One of the four folders; see {@link StoreFolder}
57
60
  * @returns Array of resolved absolute paths
58
61
  */
62
+ /**
63
+ * The four folders the toolchain keeps under one base.
64
+ *
65
+ * `service-keys/` and `sessions/` are the auth broker's. `proxy/` holds one
66
+ * ready config per proxy — the files `--config` takes. `runtime/` holds a
67
+ * record per live proxy, so one session can see another's.
68
+ */
69
+ exports.STORE_FOLDERS = [
70
+ 'service-keys',
71
+ 'sessions',
72
+ 'proxy',
73
+ 'runtime',
74
+ ];
75
+ /**
76
+ * THE base directory — one answer, not a search path.
77
+ *
78
+ * `AUTH_BROKER_PATH` relocates it, so all four folders move together; when it
79
+ * lists several, the first is the one written to. Unlike
80
+ * {@link getPlatformPaths}, this never falls back to the working directory: a
81
+ * file written beside wherever a client happened to be launched from is a file
82
+ * the next session will not find.
83
+ */
84
+ function storeBaseDir() {
85
+ const envPath = process.env.AUTH_BROKER_PATH;
86
+ if (envPath) {
87
+ // Not `/[:;]/`: on Windows that splits `C:\store` into `C`, which then
88
+ // resolves against the working directory. The delimiter is chosen from
89
+ // `process.platform` rather than taken from `path.delimiter`, which is bound
90
+ // to the host at module load and so cannot be exercised for the other one.
91
+ const first = envPath
92
+ .split(process.platform === 'win32' ? ';' : ':')
93
+ .map((p) => p.trim())
94
+ .find((p) => p.length > 0);
95
+ if (first) {
96
+ const resolved = path.resolve(first);
97
+ // Pointed at one of the folders rather than the base — a plausible
98
+ // mistake that `getPlatformPaths` already tolerates, so this must too.
99
+ // Otherwise runtime records land in `.../service-keys/runtime`.
100
+ return exports.STORE_FOLDERS.includes(path.basename(resolved))
101
+ ? path.dirname(resolved)
102
+ : resolved;
103
+ }
104
+ }
105
+ const homeDir = os.homedir();
106
+ return process.platform === 'win32'
107
+ ? path.join(homeDir, 'Documents', 'mcp-abap-adt')
108
+ : path.join(homeDir, '.config', 'mcp-abap-adt');
109
+ }
110
+ /** THE directory for one of the four folders. */
111
+ function storeDir(folder) {
112
+ return path.join(storeBaseDir(), folder);
113
+ }
59
114
  function getPlatformPaths(subfolder) {
60
115
  const paths = [];
61
116
  const isWindows = process.platform === 'win32';
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/mcp/cli.ts"],"names":[],"mappings":""}
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // src/mcp/cli.ts
4
+ const config_js_1 = require("../lib/config.js");
5
+ const logger_js_1 = require("../lib/logger.js");
6
+ const server_js_1 = require("./server.js");
7
+ /**
8
+ * What `bin/mcp-abap-adt-proxy-mcp.js` loads.
9
+ *
10
+ * Nothing but wiring: the bin parses `--help`/`--version` and then requires
11
+ * this, in the same process, so the listeners the tools start die with the
12
+ * session that asked for them.
13
+ */
14
+ (0, server_js_1.runMcpMode)((0, config_js_1.loadConfig)()).catch((error) => {
15
+ logger_js_1.logger?.error('MCP mode failed to start', {
16
+ type: 'MCP_MODE_START_FAILED',
17
+ error: error instanceof Error ? error.message : String(error),
18
+ });
19
+ process.stderr.write(`[MCP Proxy] ✗ ${error instanceof Error ? error.message : String(error)}\n`);
20
+ process.exit(1);
21
+ });
@@ -0,0 +1,33 @@
1
+ export interface ProxyConfigEntry {
2
+ /** The file name without its extension — what a client asks for. */
3
+ name: string;
4
+ file: string;
5
+ /** From the config, when it could be read. Listing is best-effort. */
6
+ destination?: string;
7
+ targetUrl?: string;
8
+ }
9
+ /**
10
+ * Where a user keeps proxy configs, beside the service keys and sessions the
11
+ * rest of the toolchain already uses.
12
+ */
13
+ export declare function proxyConfigDir(): string;
14
+ /**
15
+ * The configs on disk.
16
+ *
17
+ * A file that cannot be parsed is still LISTED, with no destination beside it.
18
+ * Hiding it would make "no such config" the error a user gets for a file
19
+ * sitting right there in the directory they are looking at.
20
+ */
21
+ export declare function listProxyConfigs(dir?: string): ProxyConfigEntry[];
22
+ /**
23
+ * The file a name refers to.
24
+ *
25
+ * Accepts either the bare name or the file name as written. A name that would
26
+ * leave the directory is refused rather than resolved: the value comes from a
27
+ * language model, and `../sessions/nvcr.env` is a real path in a real
28
+ * neighbouring folder full of credentials.
29
+ */
30
+ export declare function resolveProxyConfig(name: string, dir?: string): string;
31
+ /** Read a config's own comment header, for a listing a human can recognise. */
32
+ export declare function describeConfig(entry: ProxyConfigEntry): string;
33
+ //# sourceMappingURL=configs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configs.d.ts","sourceRoot":"","sources":["../../src/mcp/configs.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,GAAE,MAAyB,GAC7B,gBAAgB,EAAE,CAiCpB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,GAAG,GAAE,MAAyB,GAC7B,MAAM,CAgCR;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAS9D"}
@@ -0,0 +1,142 @@
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.proxyConfigDir = proxyConfigDir;
37
+ exports.listProxyConfigs = listProxyConfigs;
38
+ exports.resolveProxyConfig = resolveProxyConfig;
39
+ exports.describeConfig = describeConfig;
40
+ // src/mcp/configs.ts
41
+ const node_fs_1 = require("node:fs");
42
+ const path = __importStar(require("node:path"));
43
+ const config_js_1 = require("../lib/config.js");
44
+ const stores_js_1 = require("../lib/stores.js");
45
+ const EXTENSIONS = ['.yaml', '.yml', '.json'];
46
+ /**
47
+ * Where a user keeps proxy configs, beside the service keys and sessions the
48
+ * rest of the toolchain already uses.
49
+ */
50
+ function proxyConfigDir() {
51
+ return (0, stores_js_1.storeDir)('proxy');
52
+ }
53
+ /**
54
+ * The configs on disk.
55
+ *
56
+ * A file that cannot be parsed is still LISTED, with no destination beside it.
57
+ * Hiding it would make "no such config" the error a user gets for a file
58
+ * sitting right there in the directory they are looking at.
59
+ */
60
+ function listProxyConfigs(dir = proxyConfigDir()) {
61
+ if (!(0, node_fs_1.existsSync)(dir))
62
+ return [];
63
+ const entries = [];
64
+ for (const name of (0, node_fs_1.readdirSync)(dir).sort()) {
65
+ const file = path.join(dir, name);
66
+ const extension = path.extname(name).toLowerCase();
67
+ if (!EXTENSIONS.includes(extension))
68
+ continue;
69
+ try {
70
+ // `statSync` follows symlinks and throws on a dangling one, or on a file
71
+ // removed between the readdir and here. One bad entry costs that entry.
72
+ if (!(0, node_fs_1.statSync)(file).isFile())
73
+ continue;
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const entry = {
79
+ name: path.basename(name, path.extname(name)),
80
+ file,
81
+ };
82
+ try {
83
+ const raw = (0, config_js_1.loadRawConfigFile)(file);
84
+ if (typeof raw?.btpDestination === 'string') {
85
+ entry.destination = raw.btpDestination;
86
+ }
87
+ if (typeof raw?.targetUrl === 'string')
88
+ entry.targetUrl = raw.targetUrl;
89
+ }
90
+ catch {
91
+ // Listed without a destination. The failure belongs to whoever tries to
92
+ // START it, where it can be reported against a name the user chose.
93
+ }
94
+ entries.push(entry);
95
+ }
96
+ return entries;
97
+ }
98
+ /**
99
+ * The file a name refers to.
100
+ *
101
+ * Accepts either the bare name or the file name as written. A name that would
102
+ * leave the directory is refused rather than resolved: the value comes from a
103
+ * language model, and `../sessions/nvcr.env` is a real path in a real
104
+ * neighbouring folder full of credentials.
105
+ */
106
+ function resolveProxyConfig(name, dir = proxyConfigDir()) {
107
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) {
108
+ throw new Error(`Invalid proxy config name "${name}": it must be a name from ${dir}, not a path.`);
109
+ }
110
+ const available = listProxyConfigs(dir);
111
+ // An exact file name is never ambiguous, so it is tried first and wins.
112
+ const exact = available.find((entry) => path.basename(entry.file) === name);
113
+ if (exact)
114
+ return exact.file;
115
+ // A bare name can be answered by more than one file — `prod.yaml` and
116
+ // `prod.json` are both "prod". Taking the first in sorted order would point
117
+ // requests at a different destination than the caller meant and say nothing,
118
+ // so it is refused with both names instead.
119
+ const byName = available.filter((entry) => entry.name === name);
120
+ if (byName.length > 1) {
121
+ const files = byName.map((entry) => path.basename(entry.file)).join(', ');
122
+ throw new Error(`Ambiguous proxy config name "${name}" in ${dir}: ${files}. Name the file exactly.`);
123
+ }
124
+ if (byName.length === 1)
125
+ return byName[0].file;
126
+ const known = available.map((entry) => entry.name).join(', ');
127
+ throw new Error(known
128
+ ? `No proxy config named "${name}" in ${dir}. Available: ${known}`
129
+ : `No proxy configs found in ${dir}.`);
130
+ }
131
+ /** Read a config's own comment header, for a listing a human can recognise. */
132
+ function describeConfig(entry) {
133
+ try {
134
+ const first = (0, node_fs_1.readFileSync)(entry.file, 'utf-8')
135
+ .split('\n')
136
+ .find((line) => line.trimStart().startsWith('#'));
137
+ return first ? first.replace(/^\s*#\s?/, '').trim() : '';
138
+ }
139
+ catch {
140
+ return '';
141
+ }
142
+ }
@@ -0,0 +1,15 @@
1
+ import type { Server } from 'node:http';
2
+ /**
3
+ * Bind a server to a free port and report the one it got.
4
+ *
5
+ * Port 0 asks the OS for a free port and binds it in the same step. The
6
+ * alternative — probe for a free port, close, then bind it — leaves a window
7
+ * in which another process can take it, which is exactly the collision this
8
+ * exists to avoid. Nothing here guesses 3001.
9
+ *
10
+ * The listener is also the answer to "which port": `address()` is asked after
11
+ * the bind, so the number reported is the number in use rather than the number
12
+ * requested.
13
+ */
14
+ export declare function listenOnFreePort(server: Server, host: string): Promise<number>;
15
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ports.d.ts","sourceRoot":"","sources":["../../src/mcp/ports.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,CAoBjB"}
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listenOnFreePort = listenOnFreePort;
4
+ /**
5
+ * Bind a server to a free port and report the one it got.
6
+ *
7
+ * Port 0 asks the OS for a free port and binds it in the same step. The
8
+ * alternative — probe for a free port, close, then bind it — leaves a window
9
+ * in which another process can take it, which is exactly the collision this
10
+ * exists to avoid. Nothing here guesses 3001.
11
+ *
12
+ * The listener is also the answer to "which port": `address()` is asked after
13
+ * the bind, so the number reported is the number in use rather than the number
14
+ * requested.
15
+ */
16
+ function listenOnFreePort(server, host) {
17
+ return new Promise((resolve, reject) => {
18
+ const failed = (error) => {
19
+ server.removeListener('listening', bound);
20
+ reject(error);
21
+ };
22
+ const bound = () => {
23
+ server.removeListener('error', failed);
24
+ const address = server.address();
25
+ if (typeof address !== 'object' || address === null) {
26
+ reject(new Error('Listening on a socket with no address'));
27
+ return;
28
+ }
29
+ resolve(address.port);
30
+ };
31
+ server.once('error', failed);
32
+ server.once('listening', bound);
33
+ server.listen(0, host);
34
+ });
35
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * One live proxy, as claimed by the process that started it.
3
+ */
4
+ export interface InstanceRecord {
5
+ pid: number;
6
+ port: number;
7
+ url: string;
8
+ destination: string;
9
+ /** Which proxy config was started. */
10
+ config: string;
11
+ startedAt: string;
12
+ /**
13
+ * When the machine this was written on last booted.
14
+ *
15
+ * A pid is only unique within a boot. After a restart the same number can
16
+ * belong to an unrelated live process, and `process.kill(pid, 0)` then says
17
+ * "alive" forever — a record that outlives its writer permanently, reported by
18
+ * `proxy_status` as another session's proxy with no way to clear it but
19
+ * deleting the file by hand.
20
+ */
21
+ bootedAt: number;
22
+ }
23
+ /**
24
+ * When this machine booted, to the nearest millisecond it can manage.
25
+ *
26
+ * `os.uptime()` is seconds since boot on every platform, so this drifts by a few
27
+ * milliseconds between calls — which is why records are compared with a
28
+ * tolerance rather than for equality.
29
+ */
30
+ export declare function bootedAt(): number;
31
+ /** Does this process still exist? */
32
+ export type IsAlive = (pid: number) => boolean;
33
+ /**
34
+ * Where instance records live, following the convention `src/lib/stores.ts`
35
+ * uses for service keys and sessions.
36
+ */
37
+ export declare function defaultRuntimeDir(): string;
38
+ /**
39
+ * The live proxies on this machine, this session's and everyone else's.
40
+ *
41
+ * A record is a claim, not a fact — the process that wrote it may have crashed
42
+ * without cleaning up. So every read checks the process behind each record and
43
+ * DELETES the ones whose writer is gone, rather than merely hiding them: a
44
+ * ghost that survives the read comes back on the next one, and `proxy_status`
45
+ * goes on reporting a proxy that stopped holding its port days ago.
46
+ */
47
+ export declare class InstanceRegistry {
48
+ private readonly dir;
49
+ private readonly isAlive;
50
+ constructor(dir?: string, isAlive?: IsAlive);
51
+ private fileFor;
52
+ record(entry: InstanceRecord): void;
53
+ /** Drop this process's record for a port. Missing is not an error. */
54
+ forget(port: number): void;
55
+ live(): InstanceRecord[];
56
+ }
57
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/mcp/registry.ts"],"names":[],"mappings":"AAcA;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,IAAI,MAAM,CAEjC;AAKD,qCAAqC;AACrC,MAAM,MAAM,OAAO,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;AAe/C;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED;;;;;;;;GAQG;AACH,qBAAa,gBAAgB;IAEzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,GAAG,GAAE,MAA4B,EACjC,OAAO,GAAE,OAAuB;IAGnD,OAAO,CAAC,OAAO;IAIf,MAAM,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAWnC,sEAAsE;IACtE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAI1B,IAAI,IAAI,cAAc,EAAE;CAoCzB"}
@@ -0,0 +1,145 @@
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.InstanceRegistry = void 0;
37
+ exports.bootedAt = bootedAt;
38
+ exports.defaultRuntimeDir = defaultRuntimeDir;
39
+ // src/mcp/registry.ts
40
+ const node_fs_1 = require("node:fs");
41
+ const os = __importStar(require("node:os"));
42
+ const path = __importStar(require("node:path"));
43
+ const stores_js_1 = require("../lib/stores.js");
44
+ /**
45
+ * When this machine booted, to the nearest millisecond it can manage.
46
+ *
47
+ * `os.uptime()` is seconds since boot on every platform, so this drifts by a few
48
+ * milliseconds between calls — which is why records are compared with a
49
+ * tolerance rather than for equality.
50
+ */
51
+ function bootedAt() {
52
+ return Math.round(Date.now() - os.uptime() * 1000);
53
+ }
54
+ /** How far apart two readings of the boot time may be and still mean one boot. */
55
+ const BOOT_TOLERANCE_MS = 5000;
56
+ /**
57
+ * Signal 0 checks for the process without delivering anything. `EPERM` means it
58
+ * exists and belongs to someone else — still alive, so still holding its port.
59
+ */
60
+ const processExists = (pid) => {
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ }
65
+ catch (error) {
66
+ return error.code === 'EPERM';
67
+ }
68
+ };
69
+ /**
70
+ * Where instance records live, following the convention `src/lib/stores.ts`
71
+ * uses for service keys and sessions.
72
+ */
73
+ function defaultRuntimeDir() {
74
+ return (0, stores_js_1.storeDir)('runtime');
75
+ }
76
+ /**
77
+ * The live proxies on this machine, this session's and everyone else's.
78
+ *
79
+ * A record is a claim, not a fact — the process that wrote it may have crashed
80
+ * without cleaning up. So every read checks the process behind each record and
81
+ * DELETES the ones whose writer is gone, rather than merely hiding them: a
82
+ * ghost that survives the read comes back on the next one, and `proxy_status`
83
+ * goes on reporting a proxy that stopped holding its port days ago.
84
+ */
85
+ class InstanceRegistry {
86
+ dir;
87
+ isAlive;
88
+ constructor(dir = defaultRuntimeDir(), isAlive = processExists) {
89
+ this.dir = dir;
90
+ this.isAlive = isAlive;
91
+ }
92
+ fileFor(port) {
93
+ return path.join(this.dir, `${process.pid}-${port}.json`);
94
+ }
95
+ record(entry) {
96
+ (0, node_fs_1.mkdirSync)(this.dir, { recursive: true });
97
+ // Written beside the target and renamed over it. `writeFileSync` truncates
98
+ // first, so a reader arriving mid-write sees a partial file; a rename is
99
+ // atomic, so it sees either the whole record or no file.
100
+ const file = this.fileFor(entry.port);
101
+ const partial = `${file}.tmp-${process.pid}`;
102
+ (0, node_fs_1.writeFileSync)(partial, JSON.stringify(entry, null, 2));
103
+ (0, node_fs_1.renameSync)(partial, file);
104
+ }
105
+ /** Drop this process's record for a port. Missing is not an error. */
106
+ forget(port) {
107
+ (0, node_fs_1.rmSync)(this.fileFor(port), { force: true });
108
+ }
109
+ live() {
110
+ if (!(0, node_fs_1.existsSync)(this.dir))
111
+ return [];
112
+ const records = [];
113
+ for (const name of (0, node_fs_1.readdirSync)(this.dir)) {
114
+ if (!name.endsWith('.json'))
115
+ continue;
116
+ const file = path.join(this.dir, name);
117
+ let record;
118
+ try {
119
+ record = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf-8'));
120
+ }
121
+ catch {
122
+ // Unreadable or half-written. One bad file must not cost the caller
123
+ // the whole listing — which is the difference between a degraded
124
+ // answer and no answer at all.
125
+ continue;
126
+ }
127
+ if (typeof record?.pid !== 'number')
128
+ continue;
129
+ // A pid means nothing across a reboot, so a record from an earlier boot is
130
+ // gone whatever its pid now answers. A record with no boot time at all was
131
+ // written by an older version and cannot be judged, which is the same
132
+ // answer.
133
+ const sameBoot = typeof record.bootedAt === 'number' &&
134
+ Math.abs(record.bootedAt - bootedAt()) <= BOOT_TOLERANCE_MS;
135
+ if (sameBoot && this.isAlive(record.pid)) {
136
+ records.push(record);
137
+ }
138
+ else {
139
+ (0, node_fs_1.rmSync)(file, { force: true });
140
+ }
141
+ }
142
+ return records.sort((a, b) => a.port - b.port);
143
+ }
144
+ }
145
+ exports.InstanceRegistry = InstanceRegistry;
@@ -0,0 +1,34 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type ProxyConfig } from '../lib/config.js';
3
+ import { ProxySupervisor, type SupervisorOptions } from './supervisor.js';
4
+ /**
5
+ * The MCP mode: a stdio server whose tools start and stop this proxy.
6
+ *
7
+ * Installed as its own small command, `mcp-abap-adt-proxy-mcp`, so a client
8
+ * that wants to MANAGE proxies registers this, while a client that wants to BE
9
+ * proxied still points at `mcp-abap-adt-proxy` as before. The two are different
10
+ * jobs and conflating them would make every plain proxy carry a management
11
+ * surface it never uses.
12
+ */
13
+ export interface McpModeDeps {
14
+ /** How a credential is built for a started proxy. */
15
+ proxyFor?: SupervisorOptions['proxyFor'];
16
+ }
17
+ export declare function createMcpModeServer(_config: ProxyConfig, deps?: McpModeDeps): {
18
+ server: McpServer;
19
+ supervisor: ProxySupervisor;
20
+ };
21
+ /**
22
+ * Connect over stdio and stay up until the session ends.
23
+ *
24
+ * Every way this process can end stops the proxies first. That is the whole
25
+ * point of running them in this process: a listener that outlives the session
26
+ * that asked for it is a port nobody remembers holding.
27
+ *
28
+ * The ordering, the run-once guard and the deadline live in `createShutdown`,
29
+ * where they are tested. They were inline here, and the exit sat in a
30
+ * `.finally()` after an unbounded await — so anything that hung below produced
31
+ * exactly the process this design exists to avoid.
32
+ */
33
+ export declare function runMcpMode(config?: ProxyConfig): Promise<void>;
34
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAc,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGhE,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAG1E;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC1B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,iBAAiB,CAAC,UAAU,CAAC,CAAC;CAC1C;AAED,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,WAAW,EACpB,IAAI,GAAE,WAAgB,GACrB;IACD,MAAM,EAAE,SAAS,CAAC;IAClB,UAAU,EAAE,eAAe,CAAC;CAC7B,CAmDA;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAC9B,MAAM,GAAE,WAA0B,GACjC,OAAO,CAAC,IAAI,CAAC,CAgBf"}