@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.
- package/CHANGELOG.md +217 -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/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 +116 -631
- 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 +273 -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
|
@@ -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"}
|