@mcp-abap-adt/proxy 2.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -0
- package/LICENSE +669 -17
- package/README.md +79 -8
- package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -97
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +9 -1
- package/dist/lib/envInterpolation.d.ts +14 -2
- package/dist/lib/envInterpolation.d.ts.map +1 -1
- package/dist/lib/envInterpolation.js +34 -7
- package/dist/lib/stores.d.ts +23 -2
- package/dist/lib/stores.d.ts.map +1 -1
- package/dist/lib/stores.js +56 -1
- package/dist/mcp/cli.d.ts +2 -0
- package/dist/mcp/cli.d.ts.map +1 -0
- package/dist/mcp/cli.js +21 -0
- package/dist/mcp/configs.d.ts +33 -0
- package/dist/mcp/configs.d.ts.map +1 -0
- package/dist/mcp/configs.js +142 -0
- package/dist/mcp/ports.d.ts +15 -0
- package/dist/mcp/ports.d.ts.map +1 -0
- package/dist/mcp/ports.js +35 -0
- package/dist/mcp/registry.d.ts +57 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +145 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +79 -0
- package/dist/mcp/shutdown.d.ts +37 -0
- package/dist/mcp/shutdown.d.ts.map +1 -0
- package/dist/mcp/shutdown.js +82 -0
- package/dist/mcp/supervisor.d.ts +125 -0
- package/dist/mcp/supervisor.d.ts.map +1 -0
- package/dist/mcp/supervisor.js +330 -0
- package/dist/mcp/tools.d.ts +28 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +152 -0
- package/dist/proxy/btpProxy.d.ts +24 -73
- package/dist/proxy/btpProxy.d.ts.map +1 -1
- package/dist/proxy/btpProxy.js +65 -616
- package/dist/proxy/credentials.d.ts +45 -0
- package/dist/proxy/credentials.d.ts.map +1 -0
- package/dist/proxy/credentials.js +41 -0
- package/dist/proxy/requestHandler.d.ts +38 -0
- package/dist/proxy/requestHandler.d.ts.map +1 -0
- package/dist/proxy/requestHandler.js +73 -0
- package/dist/proxy/reverseProxy.d.ts +11 -2
- package/dist/proxy/reverseProxy.d.ts.map +1 -1
- package/dist/proxy/reverseProxy.js +52 -9
- package/dist/router/headerAnalyzer.js +2 -2
- package/dist/router/requestInterceptor.js +9 -9
- package/docs/API.md +172 -0
- package/docs/ARCHITECTURE.md +322 -0
- package/docs/CLIENT_SETUP.md +413 -0
- package/docs/CONFIGURATION.md +258 -0
- package/docs/MIGRATION-4.0.md +125 -0
- package/docs/ROUTING_LOGIC.md +126 -0
- package/docs/TROUBLESHOOTING.md +488 -0
- package/docs/USAGE.md +422 -0
- package/docs/YAML_CONFIG.md +290 -0
- package/docs/mcp-proxy-config.example.yaml +62 -0
- package/package.json +17 -10
- package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
- package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
- package/dist/proxy/cloudLlmHubProxy.js +0 -3
package/dist/lib/stores.d.ts
CHANGED
|
@@ -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
|
|
17
|
+
* @param subfolder One of the four folders; see {@link StoreFolder}
|
|
18
18
|
* @returns Array of resolved absolute paths
|
|
19
19
|
*/
|
|
20
|
-
|
|
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:
|
package/dist/lib/stores.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/lib/stores.js
CHANGED
|
@@ -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
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/mcp/cli.ts"],"names":[],"mappings":""}
|
package/dist/mcp/cli.js
ADDED
|
@@ -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"}
|