@mcp-abap-adt/proxy 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +669 -17
  3. package/README.md +79 -8
  4. package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
  5. package/dist/index.d.ts +10 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +54 -97
  8. package/dist/lib/stores.d.ts +23 -2
  9. package/dist/lib/stores.d.ts.map +1 -1
  10. package/dist/lib/stores.js +56 -1
  11. package/dist/mcp/cli.d.ts +2 -0
  12. package/dist/mcp/cli.d.ts.map +1 -0
  13. package/dist/mcp/cli.js +21 -0
  14. package/dist/mcp/configs.d.ts +33 -0
  15. package/dist/mcp/configs.d.ts.map +1 -0
  16. package/dist/mcp/configs.js +142 -0
  17. package/dist/mcp/ports.d.ts +15 -0
  18. package/dist/mcp/ports.d.ts.map +1 -0
  19. package/dist/mcp/ports.js +35 -0
  20. package/dist/mcp/registry.d.ts +57 -0
  21. package/dist/mcp/registry.d.ts.map +1 -0
  22. package/dist/mcp/registry.js +145 -0
  23. package/dist/mcp/server.d.ts +34 -0
  24. package/dist/mcp/server.d.ts.map +1 -0
  25. package/dist/mcp/server.js +79 -0
  26. package/dist/mcp/shutdown.d.ts +37 -0
  27. package/dist/mcp/shutdown.d.ts.map +1 -0
  28. package/dist/mcp/shutdown.js +82 -0
  29. package/dist/mcp/supervisor.d.ts +125 -0
  30. package/dist/mcp/supervisor.d.ts.map +1 -0
  31. package/dist/mcp/supervisor.js +330 -0
  32. package/dist/mcp/tools.d.ts +28 -0
  33. package/dist/mcp/tools.d.ts.map +1 -0
  34. package/dist/mcp/tools.js +152 -0
  35. package/dist/proxy/btpProxy.d.ts +24 -73
  36. package/dist/proxy/btpProxy.d.ts.map +1 -1
  37. package/dist/proxy/btpProxy.js +65 -616
  38. package/dist/proxy/credentials.d.ts +45 -0
  39. package/dist/proxy/credentials.d.ts.map +1 -0
  40. package/dist/proxy/credentials.js +41 -0
  41. package/dist/proxy/requestHandler.d.ts +38 -0
  42. package/dist/proxy/requestHandler.d.ts.map +1 -0
  43. package/dist/proxy/requestHandler.js +73 -0
  44. package/dist/proxy/reverseProxy.d.ts +11 -2
  45. package/dist/proxy/reverseProxy.d.ts.map +1 -1
  46. package/dist/proxy/reverseProxy.js +52 -9
  47. package/dist/router/headerAnalyzer.js +2 -2
  48. package/dist/router/requestInterceptor.js +9 -9
  49. package/docs/API.md +172 -0
  50. package/docs/ARCHITECTURE.md +322 -0
  51. package/docs/CLIENT_SETUP.md +413 -0
  52. package/docs/CONFIGURATION.md +258 -0
  53. package/docs/MIGRATION-4.0.md +125 -0
  54. package/docs/ROUTING_LOGIC.md +126 -0
  55. package/docs/TROUBLESHOOTING.md +488 -0
  56. package/docs/USAGE.md +422 -0
  57. package/docs/YAML_CONFIG.md +273 -0
  58. package/docs/mcp-proxy-config.example.yaml +62 -0
  59. package/package.json +17 -10
  60. package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
  61. package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
  62. package/dist/proxy/cloudLlmHubProxy.js +0 -3
@@ -0,0 +1,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"}