@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,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_SHUTDOWN_DEADLINE_MS = void 0;
|
|
4
|
+
exports.createShutdown = createShutdown;
|
|
5
|
+
// src/mcp/shutdown.ts
|
|
6
|
+
const logger_js_1 = require("../lib/logger.js");
|
|
7
|
+
/**
|
|
8
|
+
* How long the whole shutdown gets before the process leaves anyway.
|
|
9
|
+
*
|
|
10
|
+
* Generous next to the per-instance grace, because this is the outer bound: it
|
|
11
|
+
* exists for the case where something below refuses to finish at all, not for
|
|
12
|
+
* the ordinary one.
|
|
13
|
+
*/
|
|
14
|
+
exports.DEFAULT_SHUTDOWN_DEADLINE_MS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* The end of the session, which is the last chance to let anything go.
|
|
17
|
+
*
|
|
18
|
+
* Three properties, and each one is here because its absence is a real failure
|
|
19
|
+
* rather than a tidiness concern:
|
|
20
|
+
*
|
|
21
|
+
* **It runs once.** SIGINT, SIGTERM and stdin closing can all arrive, and two
|
|
22
|
+
* shutdowns racing means stopping the same instances twice.
|
|
23
|
+
*
|
|
24
|
+
* **It always reaches the exit.** `process.exit()` used to sit in a `.finally()`
|
|
25
|
+
* after an unbounded await, so anything that hung below it produced a process
|
|
26
|
+
* that ignores SIGTERM and keeps its ports — precisely the orphaned-listener
|
|
27
|
+
* failure that running the proxies in this process was meant to prevent.
|
|
28
|
+
*
|
|
29
|
+
* **It never rejects.** A signal handler has no caller, so a rejection here is
|
|
30
|
+
* an unhandled one, and the default is to end the process on it — which would
|
|
31
|
+
* be the exit happening for the wrong reason, skipping the release.
|
|
32
|
+
*/
|
|
33
|
+
function createShutdown(deps) {
|
|
34
|
+
const deadlineMs = deps.deadlineMs ?? exports.DEFAULT_SHUTDOWN_DEADLINE_MS;
|
|
35
|
+
let started = false;
|
|
36
|
+
return async function shutdown(why) {
|
|
37
|
+
if (started)
|
|
38
|
+
return;
|
|
39
|
+
started = true;
|
|
40
|
+
logger_js_1.logger?.info('MCP mode shutting down', { type: 'MCP_MODE_SHUTDOWN', why });
|
|
41
|
+
let timer;
|
|
42
|
+
const deadline = new Promise((resolve) => {
|
|
43
|
+
timer = setTimeout(() => resolve('deadline'), deadlineMs);
|
|
44
|
+
timer.unref?.();
|
|
45
|
+
});
|
|
46
|
+
const release = (async () => {
|
|
47
|
+
try {
|
|
48
|
+
const stopped = await deps.supervisor.stop();
|
|
49
|
+
if (stopped.length > 0) {
|
|
50
|
+
logger_js_1.logger?.info('Released proxies on shutdown', {
|
|
51
|
+
type: 'MCP_MODE_SHUTDOWN_RELEASED',
|
|
52
|
+
count: stopped.length,
|
|
53
|
+
ports: stopped.map((s) => s.port),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger_js_1.logger?.error('Failed to release proxies on shutdown', {
|
|
59
|
+
type: 'MCP_MODE_SHUTDOWN_RELEASE_FAILED',
|
|
60
|
+
error: error instanceof Error ? error.message : String(error),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await deps.closeServer();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* already gone, or never up */
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
const outcome = await Promise.race([release, deadline]);
|
|
71
|
+
if (timer)
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
if (outcome === 'deadline') {
|
|
74
|
+
logger_js_1.logger?.error('Shutdown did not finish in time; leaving anyway', {
|
|
75
|
+
type: 'MCP_MODE_SHUTDOWN_DEADLINE',
|
|
76
|
+
why,
|
|
77
|
+
deadlineMs,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
deps.exit(0);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ProxyConfig } from '../lib/config.js';
|
|
2
|
+
import { type CredentialFacade } from '../proxy/requestHandler.js';
|
|
3
|
+
import { type InstanceRecord, InstanceRegistry } from './registry.js';
|
|
4
|
+
/** Thirty minutes with no forwarded request. */
|
|
5
|
+
export declare const DEFAULT_IDLE_TIMEOUT_MS: number;
|
|
6
|
+
/**
|
|
7
|
+
* How long a stop waits for requests in flight before cutting them.
|
|
8
|
+
*
|
|
9
|
+
* `server.close()` alone is not enough and cannot be: it waits for ACTIVE
|
|
10
|
+
* connections, and a response that streams never becomes inactive. Measured on
|
|
11
|
+
* Node 26.7.0, the close callback had not fired 1500ms after a single open
|
|
12
|
+
* event stream. Waiting forever would mean `proxy_stop` never returning and —
|
|
13
|
+
* because the shutdown path awaits the same call — SIGINT, SIGTERM and stdin
|
|
14
|
+
* close all hanging with the port still held, which is precisely the
|
|
15
|
+
* orphaned-port failure running in-process was meant to prevent.
|
|
16
|
+
*
|
|
17
|
+
* So an ordinary request gets this long to finish, and then the socket goes.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_STOP_GRACE_MS = 2000;
|
|
20
|
+
export interface StartOptions {
|
|
21
|
+
/**
|
|
22
|
+
* The config's name, as it is filed in the proxy config directory. This is
|
|
23
|
+
* the unit, not the destination: four configs on disk name the same
|
|
24
|
+
* `btpDestination` and differ in target and headers, so a destination cannot
|
|
25
|
+
* identify one.
|
|
26
|
+
*/
|
|
27
|
+
name: string;
|
|
28
|
+
/**
|
|
29
|
+
* The loaded config. Its `httpPort` is deliberately IGNORED — four of the
|
|
30
|
+
* configs on disk say 3001, which is exactly the collision this mode exists
|
|
31
|
+
* to end. The port comes from the OS.
|
|
32
|
+
*/
|
|
33
|
+
config: ProxyConfig;
|
|
34
|
+
/** `0` turns the backstop off. */
|
|
35
|
+
idleTimeoutMs?: number;
|
|
36
|
+
/** How long a stop waits for requests in flight. See DEFAULT_STOP_GRACE_MS. */
|
|
37
|
+
stopGraceMs?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface StartedInstance {
|
|
40
|
+
instanceId: string;
|
|
41
|
+
/** The config that was started. */
|
|
42
|
+
name: string;
|
|
43
|
+
url: string;
|
|
44
|
+
port: number;
|
|
45
|
+
destination: string;
|
|
46
|
+
startedAt: string;
|
|
47
|
+
}
|
|
48
|
+
export interface SupervisorOptions {
|
|
49
|
+
registry?: InstanceRegistry;
|
|
50
|
+
/** Builds or reuses the credential facade for a destination's config. */
|
|
51
|
+
proxyFor: (options: StartOptions) => Promise<CredentialFacade>;
|
|
52
|
+
host?: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The listeners this process owns.
|
|
56
|
+
*
|
|
57
|
+
* They run HERE, in the MCP server's own process, and that is not a
|
|
58
|
+
* convenience. A spawned child is orphaned by any signal its parent does not
|
|
59
|
+
* forward and goes on holding the HTTP and OAuth callback ports — the reason
|
|
60
|
+
* `bin/mcp-abap-adt-proxy.js` loads the server in-process instead of spawning
|
|
61
|
+
* it. In-process, a listener dies when the stdio session dies and its port
|
|
62
|
+
* goes with it.
|
|
63
|
+
*
|
|
64
|
+
* Everything here is about letting go. A stopped instance clears its idle
|
|
65
|
+
* timer, closes its listener, releases its credential and deletes its record,
|
|
66
|
+
* in that order; a session that
|
|
67
|
+
* ends stops all of them; and an instance nobody has used for a while stops
|
|
68
|
+
* itself, because an agent that finished and forgot is the case this mode has
|
|
69
|
+
* to survive.
|
|
70
|
+
*/
|
|
71
|
+
export declare class ProxySupervisor {
|
|
72
|
+
private readonly options;
|
|
73
|
+
private readonly owned;
|
|
74
|
+
private readonly registry;
|
|
75
|
+
private readonly host;
|
|
76
|
+
constructor(options: SupervisorOptions);
|
|
77
|
+
start(options: StartOptions): Promise<StartedInstance>;
|
|
78
|
+
/** Stop one instance, or every one this process owns. */
|
|
79
|
+
stop(instanceId?: string): Promise<StartedInstance[]>;
|
|
80
|
+
/** What this process is running. */
|
|
81
|
+
mine(): StartedInstance[];
|
|
82
|
+
/**
|
|
83
|
+
* Live proxies started by other sessions. Dead claims are pruned on read.
|
|
84
|
+
*
|
|
85
|
+
* Anything bearing this process's pid is OURS, owned or not. A record we wrote
|
|
86
|
+
* and no longer own is an orphan of our own making — `forget()` failed, or a
|
|
87
|
+
* second supervisor shares the directory — and reporting it as "another
|
|
88
|
+
* session's, not yours to stop" would be actively misleading about the one
|
|
89
|
+
* thing the caller would act on.
|
|
90
|
+
*/
|
|
91
|
+
others(): InstanceRecord[];
|
|
92
|
+
/**
|
|
93
|
+
* Undo a start that did not finish: give the port back and let the credential
|
|
94
|
+
* go. Nothing here may throw — it runs on the way out of a failure, and a
|
|
95
|
+
* second failure would hide the first.
|
|
96
|
+
*/
|
|
97
|
+
private release;
|
|
98
|
+
/**
|
|
99
|
+
* Close the listener and make sure the port is actually free afterwards.
|
|
100
|
+
*
|
|
101
|
+
* Idle keep-alive sockets go immediately — they are holding the port for
|
|
102
|
+
* nothing. Anything still carrying a request gets `stopGraceMs`, and then
|
|
103
|
+
* goes too, because a stream will not end on its own and the caller asked
|
|
104
|
+
* for this port back.
|
|
105
|
+
*/
|
|
106
|
+
private closeListener;
|
|
107
|
+
private describe;
|
|
108
|
+
/**
|
|
109
|
+
* Restart the countdown. `unref` so a pending timer never keeps the process
|
|
110
|
+
* alive on its own — the backstop exists to release things, not to hold one.
|
|
111
|
+
*/
|
|
112
|
+
private armIdle;
|
|
113
|
+
/**
|
|
114
|
+
* A request arrived: stop counting down until it is done.
|
|
115
|
+
*
|
|
116
|
+
* Clearing the handle as well as the timer is what closes the leak the old
|
|
117
|
+
* `touch()` had — it replaced `owned.idle` while `stop()` already held the
|
|
118
|
+
* previous handle, so the replacement was never cleared and later fired
|
|
119
|
+
* against an instance that had gone.
|
|
120
|
+
*/
|
|
121
|
+
private requestStarted;
|
|
122
|
+
/** The last one finished: start counting down again. */
|
|
123
|
+
private requestFinished;
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=supervisor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supervisor.d.ts","sourceRoot":"","sources":["../../src/mcp/supervisor.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAY,KAAK,cAAc,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEhF,gDAAgD;AAChD,eAAO,MAAM,uBAAuB,QAAiB,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,MAAM,WAAW,YAAY;IAC3B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB,kCAAkC;IAClC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAoBD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,yEAAyE;IACzE,QAAQ,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,eAAe;IAKd,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmB;IAC5C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;gBAED,OAAO,EAAE,iBAAiB;IAKjD,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC;IAwG5D,yDAAyD;IACnD,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAiD3D,oCAAoC;IACpC,IAAI,IAAI,eAAe,EAAE;IAIzB;;;;;;;;OAQG;IACH,MAAM,IAAI,cAAc,EAAE;IAI1B;;;;OAIG;IACH,OAAO,CAAC,OAAO;IAkBf;;;;;;;OAOG;YACW,aAAa;IA4B3B,OAAO,CAAC,QAAQ;IAahB;;;OAGG;IACH,OAAO,CAAC,OAAO;IAsBf;;;;;;;OAOG;IACH,OAAO,CAAC,cAAc;IAUtB,wDAAwD;IACxD,OAAO,CAAC,eAAe;CAQxB"}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProxySupervisor = exports.DEFAULT_STOP_GRACE_MS = exports.DEFAULT_IDLE_TIMEOUT_MS = void 0;
|
|
4
|
+
// src/mcp/supervisor.ts
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const node_http_1 = require("node:http");
|
|
7
|
+
const logger_js_1 = require("../lib/logger.js");
|
|
8
|
+
const requestHandler_js_1 = require("../proxy/requestHandler.js");
|
|
9
|
+
const ports_js_1 = require("./ports.js");
|
|
10
|
+
const registry_js_1 = require("./registry.js");
|
|
11
|
+
/** Thirty minutes with no forwarded request. */
|
|
12
|
+
exports.DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* How long a stop waits for requests in flight before cutting them.
|
|
15
|
+
*
|
|
16
|
+
* `server.close()` alone is not enough and cannot be: it waits for ACTIVE
|
|
17
|
+
* connections, and a response that streams never becomes inactive. Measured on
|
|
18
|
+
* Node 26.7.0, the close callback had not fired 1500ms after a single open
|
|
19
|
+
* event stream. Waiting forever would mean `proxy_stop` never returning and —
|
|
20
|
+
* because the shutdown path awaits the same call — SIGINT, SIGTERM and stdin
|
|
21
|
+
* close all hanging with the port still held, which is precisely the
|
|
22
|
+
* orphaned-port failure running in-process was meant to prevent.
|
|
23
|
+
*
|
|
24
|
+
* So an ordinary request gets this long to finish, and then the socket goes.
|
|
25
|
+
*/
|
|
26
|
+
exports.DEFAULT_STOP_GRACE_MS = 2000;
|
|
27
|
+
/**
|
|
28
|
+
* The listeners this process owns.
|
|
29
|
+
*
|
|
30
|
+
* They run HERE, in the MCP server's own process, and that is not a
|
|
31
|
+
* convenience. A spawned child is orphaned by any signal its parent does not
|
|
32
|
+
* forward and goes on holding the HTTP and OAuth callback ports — the reason
|
|
33
|
+
* `bin/mcp-abap-adt-proxy.js` loads the server in-process instead of spawning
|
|
34
|
+
* it. In-process, a listener dies when the stdio session dies and its port
|
|
35
|
+
* goes with it.
|
|
36
|
+
*
|
|
37
|
+
* Everything here is about letting go. A stopped instance clears its idle
|
|
38
|
+
* timer, closes its listener, releases its credential and deletes its record,
|
|
39
|
+
* in that order; a session that
|
|
40
|
+
* ends stops all of them; and an instance nobody has used for a while stops
|
|
41
|
+
* itself, because an agent that finished and forgot is the case this mode has
|
|
42
|
+
* to survive.
|
|
43
|
+
*/
|
|
44
|
+
class ProxySupervisor {
|
|
45
|
+
options;
|
|
46
|
+
owned = new Map();
|
|
47
|
+
registry;
|
|
48
|
+
host;
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.options = options;
|
|
51
|
+
this.registry = options.registry ?? new registry_js_1.InstanceRegistry();
|
|
52
|
+
this.host = options.host ?? '127.0.0.1';
|
|
53
|
+
}
|
|
54
|
+
async start(options) {
|
|
55
|
+
const facade = await this.options.proxyFor(options);
|
|
56
|
+
// Ask for the header once, BEFORE binding a port or writing a record.
|
|
57
|
+
//
|
|
58
|
+
// Otherwise the interactive browser login fires on the first forwarded
|
|
59
|
+
// request: a window opening in the middle of some unrelated tool call, a
|
|
60
|
+
// five-minute timeout, and a 502 whose reason only reaches stderr. The
|
|
61
|
+
// caller asked for a proxy here, so here is where it learns it cannot have
|
|
62
|
+
// one — and a failed start leaves nothing behind, because nothing was taken
|
|
63
|
+
// yet.
|
|
64
|
+
const destination = options.config.btpDestination;
|
|
65
|
+
if (destination) {
|
|
66
|
+
await facade.getAuthorizationHeader(destination);
|
|
67
|
+
}
|
|
68
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? exports.DEFAULT_IDLE_TIMEOUT_MS;
|
|
69
|
+
const instanceId = (0, node_crypto_1.randomUUID)();
|
|
70
|
+
const handle = (0, requestHandler_js_1.createProxyRequestHandler)({
|
|
71
|
+
config: {
|
|
72
|
+
btpDestination: options.config.btpDestination,
|
|
73
|
+
targetUrl: options.config.targetUrl,
|
|
74
|
+
defaultHeaders: options.config.defaultHeaders,
|
|
75
|
+
},
|
|
76
|
+
proxy: async () => facade,
|
|
77
|
+
// Deliberately no policy: an authentication failure answers the request
|
|
78
|
+
// and the process lives. Exiting here would take the MCP server down and
|
|
79
|
+
// the client's session with it.
|
|
80
|
+
});
|
|
81
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
82
|
+
this.requestStarted(instanceId);
|
|
83
|
+
res.on('close', () => this.requestFinished(instanceId));
|
|
84
|
+
handle(req, res).catch((error) => {
|
|
85
|
+
logger_js_1.logger?.error('Proxy request failed inside the MCP mode', {
|
|
86
|
+
type: 'MCP_MODE_REQUEST_ERROR',
|
|
87
|
+
error: error instanceof Error ? error.message : String(error),
|
|
88
|
+
});
|
|
89
|
+
if (!res.headersSent) {
|
|
90
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
91
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// Everything from here on can fail with a port already bound and a
|
|
96
|
+
// credential already alive, so it is all inside one attempt that undoes
|
|
97
|
+
// itself. A synchronous `registry.record()` throwing on a read-only
|
|
98
|
+
// filesystem used to reject `start()` while leaving the listener bound, the
|
|
99
|
+
// credential held, the instance in `owned` and no idle timer to ever reach
|
|
100
|
+
// it — a proxy running that nobody had been told about.
|
|
101
|
+
let port;
|
|
102
|
+
try {
|
|
103
|
+
port = await (0, ports_js_1.listenOnFreePort)(server, this.host);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
this.release(facade, server, 'listen failed');
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
const started = {
|
|
110
|
+
instanceId,
|
|
111
|
+
name: options.name,
|
|
112
|
+
port,
|
|
113
|
+
url: `http://${this.host}:${port}`,
|
|
114
|
+
destination: options.config.btpDestination ?? '(none)',
|
|
115
|
+
startedAt: new Date().toISOString(),
|
|
116
|
+
server,
|
|
117
|
+
facade,
|
|
118
|
+
idleTimeoutMs,
|
|
119
|
+
stopGraceMs: options.stopGraceMs ?? exports.DEFAULT_STOP_GRACE_MS,
|
|
120
|
+
inFlight: 0,
|
|
121
|
+
};
|
|
122
|
+
this.owned.set(instanceId, started);
|
|
123
|
+
try {
|
|
124
|
+
this.registry.record({
|
|
125
|
+
pid: process.pid,
|
|
126
|
+
port,
|
|
127
|
+
url: started.url,
|
|
128
|
+
destination: started.destination,
|
|
129
|
+
config: started.name,
|
|
130
|
+
startedAt: started.startedAt,
|
|
131
|
+
bootedAt: (0, registry_js_1.bootedAt)(),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
this.owned.delete(instanceId);
|
|
136
|
+
this.release(facade, server, 'could not write the instance record');
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
if (idleTimeoutMs > 0) {
|
|
140
|
+
started.idle = this.armIdle(instanceId, idleTimeoutMs);
|
|
141
|
+
}
|
|
142
|
+
logger_js_1.logger?.info('Proxy started by the MCP mode', {
|
|
143
|
+
type: 'MCP_MODE_PROXY_STARTED',
|
|
144
|
+
instanceId,
|
|
145
|
+
name: options.name,
|
|
146
|
+
port,
|
|
147
|
+
destination: started.destination,
|
|
148
|
+
});
|
|
149
|
+
return this.describe(started);
|
|
150
|
+
}
|
|
151
|
+
/** Stop one instance, or every one this process owns. */
|
|
152
|
+
async stop(instanceId) {
|
|
153
|
+
const targets = instanceId === undefined
|
|
154
|
+
? [...this.owned.values()]
|
|
155
|
+
: [this.owned.get(instanceId)].filter(Boolean);
|
|
156
|
+
const stopped = [];
|
|
157
|
+
for (const target of targets) {
|
|
158
|
+
if (target.idle)
|
|
159
|
+
clearTimeout(target.idle);
|
|
160
|
+
await this.closeListener(target);
|
|
161
|
+
// The port is half of it. The broker behind the credential holds cached
|
|
162
|
+
// service-key lookups, so letting go of the listener and keeping those
|
|
163
|
+
// would be releasing the visible resource and holding the rest.
|
|
164
|
+
//
|
|
165
|
+
// Guarded, and so is forgetting the record: a credential that throws on
|
|
166
|
+
// the way out used to abort this loop between closing the server and
|
|
167
|
+
// deleting the record, leaving both the instance and its file behind —
|
|
168
|
+
// and rejecting on top of it, which from the idle timer means an
|
|
169
|
+
// unhandled rejection and a dead MCP session.
|
|
170
|
+
try {
|
|
171
|
+
target.facade.dispose?.();
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
logger_js_1.logger?.error('A credential threw while being released', {
|
|
175
|
+
type: 'MCP_MODE_DISPOSE_FAILED',
|
|
176
|
+
instanceId: target.instanceId,
|
|
177
|
+
error: error instanceof Error ? error.message : String(error),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
this.owned.delete(target.instanceId);
|
|
181
|
+
try {
|
|
182
|
+
this.registry.forget(target.port);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
logger_js_1.logger?.error('Could not delete the instance record', {
|
|
186
|
+
type: 'MCP_MODE_FORGET_FAILED',
|
|
187
|
+
port: target.port,
|
|
188
|
+
error: error instanceof Error ? error.message : String(error),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
stopped.push(this.describe(target));
|
|
192
|
+
logger_js_1.logger?.info('Proxy stopped by the MCP mode', {
|
|
193
|
+
type: 'MCP_MODE_PROXY_STOPPED',
|
|
194
|
+
instanceId: target.instanceId,
|
|
195
|
+
port: target.port,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return stopped;
|
|
199
|
+
}
|
|
200
|
+
/** What this process is running. */
|
|
201
|
+
mine() {
|
|
202
|
+
return [...this.owned.values()].map((owned) => this.describe(owned));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Live proxies started by other sessions. Dead claims are pruned on read.
|
|
206
|
+
*
|
|
207
|
+
* Anything bearing this process's pid is OURS, owned or not. A record we wrote
|
|
208
|
+
* and no longer own is an orphan of our own making — `forget()` failed, or a
|
|
209
|
+
* second supervisor shares the directory — and reporting it as "another
|
|
210
|
+
* session's, not yours to stop" would be actively misleading about the one
|
|
211
|
+
* thing the caller would act on.
|
|
212
|
+
*/
|
|
213
|
+
others() {
|
|
214
|
+
return this.registry.live().filter((record) => record.pid !== process.pid);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Undo a start that did not finish: give the port back and let the credential
|
|
218
|
+
* go. Nothing here may throw — it runs on the way out of a failure, and a
|
|
219
|
+
* second failure would hide the first.
|
|
220
|
+
*/
|
|
221
|
+
release(facade, server, why) {
|
|
222
|
+
logger_js_1.logger?.error('Abandoning a proxy that failed to start', {
|
|
223
|
+
type: 'MCP_MODE_START_FAILED',
|
|
224
|
+
why,
|
|
225
|
+
});
|
|
226
|
+
try {
|
|
227
|
+
server.closeAllConnections?.();
|
|
228
|
+
server.close();
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
/* never listened, or already closed */
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
facade.dispose?.();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
/* the credential's problem, not this one's */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Close the listener and make sure the port is actually free afterwards.
|
|
242
|
+
*
|
|
243
|
+
* Idle keep-alive sockets go immediately — they are holding the port for
|
|
244
|
+
* nothing. Anything still carrying a request gets `stopGraceMs`, and then
|
|
245
|
+
* goes too, because a stream will not end on its own and the caller asked
|
|
246
|
+
* for this port back.
|
|
247
|
+
*/
|
|
248
|
+
async closeListener(target) {
|
|
249
|
+
const closed = new Promise((resolve) => target.server.close(() => resolve()));
|
|
250
|
+
target.server.closeIdleConnections?.();
|
|
251
|
+
let forced;
|
|
252
|
+
const grace = new Promise((resolve) => {
|
|
253
|
+
forced = setTimeout(() => {
|
|
254
|
+
logger_js_1.logger?.info('Cutting requests still in flight to free the port', {
|
|
255
|
+
type: 'MCP_MODE_STOP_FORCED',
|
|
256
|
+
instanceId: target.instanceId,
|
|
257
|
+
port: target.port,
|
|
258
|
+
stopGraceMs: target.stopGraceMs,
|
|
259
|
+
});
|
|
260
|
+
target.server.closeAllConnections?.();
|
|
261
|
+
resolve();
|
|
262
|
+
}, target.stopGraceMs);
|
|
263
|
+
forced.unref?.();
|
|
264
|
+
});
|
|
265
|
+
await Promise.race([closed, grace]);
|
|
266
|
+
if (forced)
|
|
267
|
+
clearTimeout(forced);
|
|
268
|
+
// After closeAllConnections the close callback fires; wait for it so the
|
|
269
|
+
// port is demonstrably free when this returns rather than probably free.
|
|
270
|
+
await closed;
|
|
271
|
+
}
|
|
272
|
+
describe(owned) {
|
|
273
|
+
const { server: _server, idle: _idle, facade: _facade, idleTimeoutMs: _idleTimeoutMs, stopGraceMs: _stopGraceMs, inFlight: _inFlight, ...rest } = owned;
|
|
274
|
+
return rest;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Restart the countdown. `unref` so a pending timer never keeps the process
|
|
278
|
+
* alive on its own — the backstop exists to release things, not to hold one.
|
|
279
|
+
*/
|
|
280
|
+
armIdle(instanceId, ms) {
|
|
281
|
+
const timer = setTimeout(() => {
|
|
282
|
+
logger_js_1.logger?.info('Proxy stopped after sitting idle', {
|
|
283
|
+
type: 'MCP_MODE_PROXY_IDLE_STOP',
|
|
284
|
+
instanceId,
|
|
285
|
+
idleTimeoutMs: ms,
|
|
286
|
+
});
|
|
287
|
+
// Caught, not floated: `stop()` can reject, and an unhandled rejection
|
|
288
|
+
// from a timer ends the process — taking the client's MCP session with a
|
|
289
|
+
// proxy that merely sat unused.
|
|
290
|
+
void this.stop(instanceId).catch((error) => {
|
|
291
|
+
logger_js_1.logger?.error('Failed to stop an idle proxy', {
|
|
292
|
+
type: 'MCP_MODE_IDLE_STOP_FAILED',
|
|
293
|
+
instanceId,
|
|
294
|
+
error: error instanceof Error ? error.message : String(error),
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}, ms);
|
|
298
|
+
timer.unref?.();
|
|
299
|
+
return timer;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* A request arrived: stop counting down until it is done.
|
|
303
|
+
*
|
|
304
|
+
* Clearing the handle as well as the timer is what closes the leak the old
|
|
305
|
+
* `touch()` had — it replaced `owned.idle` while `stop()` already held the
|
|
306
|
+
* previous handle, so the replacement was never cleared and later fired
|
|
307
|
+
* against an instance that had gone.
|
|
308
|
+
*/
|
|
309
|
+
requestStarted(instanceId) {
|
|
310
|
+
const owned = this.owned.get(instanceId);
|
|
311
|
+
if (!owned)
|
|
312
|
+
return;
|
|
313
|
+
owned.inFlight += 1;
|
|
314
|
+
if (owned.idle) {
|
|
315
|
+
clearTimeout(owned.idle);
|
|
316
|
+
owned.idle = undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** The last one finished: start counting down again. */
|
|
320
|
+
requestFinished(instanceId) {
|
|
321
|
+
const owned = this.owned.get(instanceId);
|
|
322
|
+
if (!owned)
|
|
323
|
+
return;
|
|
324
|
+
owned.inFlight = Math.max(0, owned.inFlight - 1);
|
|
325
|
+
if (owned.inFlight === 0 && !owned.idle && owned.idleTimeoutMs > 0) {
|
|
326
|
+
owned.idle = this.armIdle(instanceId, owned.idleTimeoutMs);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
exports.ProxySupervisor = ProxySupervisor;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type ProxySupervisor } from './supervisor.js';
|
|
3
|
+
/**
|
|
4
|
+
* The thing the client has to keep being told.
|
|
5
|
+
*
|
|
6
|
+
* A running proxy holds a TCP port and a live credential for the destination.
|
|
7
|
+
* The client here is a language model reading tool descriptions selectively,
|
|
8
|
+
* so this appears in the description of `proxy_start`, in the text that comes
|
|
9
|
+
* back with the URL, and in the server's own instructions. Three places for one
|
|
10
|
+
* sentence is not redundancy — it is the difference between a reminder that is
|
|
11
|
+
* read when the work starts and one that is read when the work finishes.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SHUTDOWN_REMINDER: string;
|
|
14
|
+
export interface ToolResult {
|
|
15
|
+
content: {
|
|
16
|
+
type: 'text';
|
|
17
|
+
text: string;
|
|
18
|
+
}[];
|
|
19
|
+
}
|
|
20
|
+
export interface ProxyTool {
|
|
21
|
+
name: string;
|
|
22
|
+
title: string;
|
|
23
|
+
description: string;
|
|
24
|
+
inputSchema: z.ZodRawShape;
|
|
25
|
+
handler: (args: Record<string, unknown>) => Promise<ToolResult>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createProxyTools(supervisor: ProxySupervisor, configDir?: string): ProxyTool[];
|
|
28
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/mcp/tools.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEhF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,QAGc,CAAC;AAE7C,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,CAAC,CAAC,WAAW,CAAC;IAC3B,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;CACjE;AAMD,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,eAAe,EAC3B,SAAS,GAAE,MAAyB,GACnC,SAAS,EAAE,CA2Jb"}
|