@mcp-abap-adt/proxy 1.6.4 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/LICENSE +669 -17
  3. package/README.md +79 -8
  4. package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
  5. package/dist/index.d.ts +10 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +54 -97
  8. package/dist/lib/stores.d.ts +23 -2
  9. package/dist/lib/stores.d.ts.map +1 -1
  10. package/dist/lib/stores.js +56 -1
  11. package/dist/mcp/cli.d.ts +2 -0
  12. package/dist/mcp/cli.d.ts.map +1 -0
  13. package/dist/mcp/cli.js +21 -0
  14. package/dist/mcp/configs.d.ts +33 -0
  15. package/dist/mcp/configs.d.ts.map +1 -0
  16. package/dist/mcp/configs.js +142 -0
  17. package/dist/mcp/ports.d.ts +15 -0
  18. package/dist/mcp/ports.d.ts.map +1 -0
  19. package/dist/mcp/ports.js +35 -0
  20. package/dist/mcp/registry.d.ts +57 -0
  21. package/dist/mcp/registry.d.ts.map +1 -0
  22. package/dist/mcp/registry.js +145 -0
  23. package/dist/mcp/server.d.ts +34 -0
  24. package/dist/mcp/server.d.ts.map +1 -0
  25. package/dist/mcp/server.js +79 -0
  26. package/dist/mcp/shutdown.d.ts +37 -0
  27. package/dist/mcp/shutdown.d.ts.map +1 -0
  28. package/dist/mcp/shutdown.js +82 -0
  29. package/dist/mcp/supervisor.d.ts +125 -0
  30. package/dist/mcp/supervisor.d.ts.map +1 -0
  31. package/dist/mcp/supervisor.js +330 -0
  32. package/dist/mcp/tools.d.ts +28 -0
  33. package/dist/mcp/tools.d.ts.map +1 -0
  34. package/dist/mcp/tools.js +152 -0
  35. package/dist/proxy/btpProxy.d.ts +24 -73
  36. package/dist/proxy/btpProxy.d.ts.map +1 -1
  37. package/dist/proxy/btpProxy.js +116 -631
  38. package/dist/proxy/credentials.d.ts +45 -0
  39. package/dist/proxy/credentials.d.ts.map +1 -0
  40. package/dist/proxy/credentials.js +41 -0
  41. package/dist/proxy/requestHandler.d.ts +38 -0
  42. package/dist/proxy/requestHandler.d.ts.map +1 -0
  43. package/dist/proxy/requestHandler.js +73 -0
  44. package/dist/proxy/reverseProxy.d.ts +11 -2
  45. package/dist/proxy/reverseProxy.d.ts.map +1 -1
  46. package/dist/proxy/reverseProxy.js +52 -9
  47. package/dist/router/headerAnalyzer.js +2 -2
  48. package/dist/router/requestInterceptor.js +9 -9
  49. package/docs/API.md +172 -0
  50. package/docs/ARCHITECTURE.md +322 -0
  51. package/docs/CLIENT_SETUP.md +413 -0
  52. package/docs/CONFIGURATION.md +258 -0
  53. package/docs/MIGRATION-4.0.md +125 -0
  54. package/docs/ROUTING_LOGIC.md +126 -0
  55. package/docs/TROUBLESHOOTING.md +488 -0
  56. package/docs/USAGE.md +422 -0
  57. package/docs/YAML_CONFIG.md +273 -0
  58. package/docs/mcp-proxy-config.example.yaml +62 -0
  59. package/package.json +17 -10
  60. package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
  61. package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
  62. package/dist/proxy/cloudLlmHubProxy.js +0 -3
@@ -0,0 +1,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"}
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createMcpModeServer = createMcpModeServer;
4
+ exports.runMcpMode = runMcpMode;
5
+ // src/mcp/server.ts
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
9
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
10
+ const config_js_1 = require("../lib/config.js");
11
+ const btpProxy_js_1 = require("../proxy/btpProxy.js");
12
+ const shutdown_js_1 = require("./shutdown.js");
13
+ const supervisor_js_1 = require("./supervisor.js");
14
+ const tools_js_1 = require("./tools.js");
15
+ function createMcpModeServer(_config, deps = {}) {
16
+ const supervisor = new supervisor_js_1.ProxySupervisor({
17
+ // The loaded proxy config, unchanged and un-merged.
18
+ //
19
+ // This used to spread a baseline under it, described as "the fallback for
20
+ // anything a proxy config leaves out". It was not a fallback.
21
+ // `applyDefaults` sets EVERY key, so a config saying nothing about headers
22
+ // still arrives carrying `defaultHeaders: undefined` — and spreading that
23
+ // over the baseline erased it. Since the baseline is the documented home of
24
+ // `x-sap-login` / `x-sap-password`, the failure was a request going out
25
+ // with no ABAP credentials and nothing saying so.
26
+ //
27
+ // No fallback is needed: `loadConfig(file)` already overlays the CLI flags
28
+ // this command was given, which is how `--unsafe` reaches every proxy.
29
+ proxyFor: deps.proxyFor ?? ((options) => (0, btpProxy_js_1.createBtpProxy)(options.config)),
30
+ });
31
+ // Read rather than written down: a version literal here drifts from the one
32
+ // that ships the moment a release bumps package.json and not this file.
33
+ //
34
+ // `__dirname` rather than `import.meta`: the sources are authored as ESM but
35
+ // compiled to CommonJS, where `import.meta` does not exist — and the build
36
+ // says so rather than letting it fail in a client's hands.
37
+ const { version } = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', '..', 'package.json'), 'utf-8'));
38
+ const server = new mcp_js_1.McpServer({ name: '@mcp-abap-adt/proxy-mcp', version }, {
39
+ instructions: 'Starts and stops local authenticating proxies for SAP BTP ' +
40
+ `destinations. ${tools_js_1.SHUTDOWN_REMINDER}`,
41
+ });
42
+ for (const tool of (0, tools_js_1.createProxyTools)(supervisor)) {
43
+ server.registerTool(tool.name, {
44
+ title: tool.title,
45
+ description: tool.description,
46
+ inputSchema: tool.inputSchema,
47
+ },
48
+ // The SDK's callback types are generic over the schema; the tool's own
49
+ // handler is already typed against it.
50
+ tool.handler);
51
+ }
52
+ return { server, supervisor };
53
+ }
54
+ /**
55
+ * Connect over stdio and stay up until the session ends.
56
+ *
57
+ * Every way this process can end stops the proxies first. That is the whole
58
+ * point of running them in this process: a listener that outlives the session
59
+ * that asked for it is a port nobody remembers holding.
60
+ *
61
+ * The ordering, the run-once guard and the deadline live in `createShutdown`,
62
+ * where they are tested. They were inline here, and the exit sat in a
63
+ * `.finally()` after an unbounded await — so anything that hung below produced
64
+ * exactly the process this design exists to avoid.
65
+ */
66
+ async function runMcpMode(config = (0, config_js_1.loadConfig)()) {
67
+ const { server, supervisor } = createMcpModeServer(config);
68
+ const shutdown = (0, shutdown_js_1.createShutdown)({
69
+ supervisor,
70
+ closeServer: () => server.close(),
71
+ exit: (code) => process.exit(code),
72
+ });
73
+ for (const signal of ['SIGTERM', 'SIGINT']) {
74
+ process.on(signal, () => void shutdown(signal));
75
+ }
76
+ // The client hanging up is the ordinary case, not an exceptional one.
77
+ process.stdin.on('close', () => void shutdown('stdin closed'));
78
+ await server.connect(new stdio_js_1.StdioServerTransport());
79
+ }
@@ -0,0 +1,37 @@
1
+ import type { StartedInstance } from './supervisor.js';
2
+ /**
3
+ * How long the whole shutdown gets before the process leaves anyway.
4
+ *
5
+ * Generous next to the per-instance grace, because this is the outer bound: it
6
+ * exists for the case where something below refuses to finish at all, not for
7
+ * the ordinary one.
8
+ */
9
+ export declare const DEFAULT_SHUTDOWN_DEADLINE_MS = 10000;
10
+ export interface ShutdownDeps {
11
+ supervisor: {
12
+ stop(instanceId?: string): Promise<StartedInstance[]>;
13
+ };
14
+ closeServer: () => Promise<void>;
15
+ exit: (code: number) => void;
16
+ deadlineMs?: number;
17
+ }
18
+ /**
19
+ * The end of the session, which is the last chance to let anything go.
20
+ *
21
+ * Three properties, and each one is here because its absence is a real failure
22
+ * rather than a tidiness concern:
23
+ *
24
+ * **It runs once.** SIGINT, SIGTERM and stdin closing can all arrive, and two
25
+ * shutdowns racing means stopping the same instances twice.
26
+ *
27
+ * **It always reaches the exit.** `process.exit()` used to sit in a `.finally()`
28
+ * after an unbounded await, so anything that hung below it produced a process
29
+ * that ignores SIGTERM and keeps its ports — precisely the orphaned-listener
30
+ * failure that running the proxies in this process was meant to prevent.
31
+ *
32
+ * **It never rejects.** A signal handler has no caller, so a rejection here is
33
+ * an unhandled one, and the default is to end the process on it — which would
34
+ * be the exit happening for the wrong reason, skipping the release.
35
+ */
36
+ export declare function createShutdown(deps: ShutdownDeps): (why: string) => Promise<void>;
37
+ //# sourceMappingURL=shutdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shutdown.d.ts","sourceRoot":"","sources":["../../src/mcp/shutdown.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE;QAAE,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAA;KAAE,CAAC;IACtE,WAAW,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,YAAY,GACjB,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAmDhC"}