@sentropic/h2a 0.94.2 → 0.95.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/dist/bin-routing.d.ts.map +1 -1
- package/dist/bin-routing.js +2 -0
- package/dist/bin-routing.js.map +1 -1
- package/dist/bin.js +33 -1
- package/dist/bin.js.map +1 -1
- package/dist/cli-command-map.d.ts.map +1 -1
- package/dist/cli-command-map.js +1 -0
- package/dist/cli-command-map.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +144 -5
- package/dist/cli.js.map +1 -1
- package/dist/hosts/codex.d.ts +3 -1
- package/dist/hosts/codex.d.ts.map +1 -1
- package/dist/hosts/codex.js +5 -1
- package/dist/hosts/codex.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/drive/index.d.ts +2 -1
- package/dist/runtime/drive/index.d.ts.map +1 -1
- package/dist/runtime/drive/index.js +15 -8
- package/dist/runtime/drive/index.js.map +1 -1
- package/dist/runtime/mcp-central.d.ts +76 -0
- package/dist/runtime/mcp-central.d.ts.map +1 -0
- package/dist/runtime/mcp-central.js +838 -0
- package/dist/runtime/mcp-central.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in, machine-local central MCP server.
|
|
3
|
+
*
|
|
4
|
+
* The marker deliberately lives beneath a UID-only runtime address. In
|
|
5
|
+
* particular, neither the requested HTTP endpoint nor XDG_RUNTIME_DIR can
|
|
6
|
+
* influence it: a varying rendezvous address cannot provide mutual exclusion.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { lstatSync, linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { once } from "node:events";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { StreamableHTTPTransport } from "@hono/mcp";
|
|
13
|
+
import { serve } from "@hono/node-server";
|
|
14
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
import { currentCliVersion } from "./upgrade/index.js";
|
|
19
|
+
import { createMcpServer, isMcpTransportResult } from "./mcp/server.js";
|
|
20
|
+
export const H2A_MCP_CENTRAL_ENV = "H2A_MCP_CENTRAL";
|
|
21
|
+
export const H2A_MCP_CENTRAL_ENDPOINT_ENV = "H2A_MCP_CENTRAL_ENDPOINT";
|
|
22
|
+
const CENTRAL_RUNTIME_DIRECTORY = "h2a-mcp-central";
|
|
23
|
+
const CENTRAL_MARKER_FILE = "marker.json";
|
|
24
|
+
const CENTRAL_RECLAIM_LOCK_FILE = "reclaim.lock";
|
|
25
|
+
const CENTRAL_PING_PATH = "/_h2a-central/ping";
|
|
26
|
+
const CENTRAL_LIVENESS_TIMEOUT_MS = 1_500;
|
|
27
|
+
const CENTRAL_LIVENESS_ATTEMPTS = 3;
|
|
28
|
+
const CENTRAL_LIVENESS_BACKOFF_MS = 100;
|
|
29
|
+
let runtimeOwnership;
|
|
30
|
+
function loadRuntimeOwnership() {
|
|
31
|
+
const runtimePkg = "@sentropic/h2a-runtime";
|
|
32
|
+
runtimeOwnership ??= import(runtimePkg).then((runtime) => {
|
|
33
|
+
if (typeof runtime.assertOwnedByCurrentUser !== "function" ||
|
|
34
|
+
typeof runtime.sameNativeTerminalSocket !== "function") {
|
|
35
|
+
throw new Error("@sentropic/h2a-runtime does not expose central MCP ownership helpers");
|
|
36
|
+
}
|
|
37
|
+
return runtime;
|
|
38
|
+
});
|
|
39
|
+
return runtimeOwnership;
|
|
40
|
+
}
|
|
41
|
+
function uid() {
|
|
42
|
+
if (typeof process.getuid !== "function") {
|
|
43
|
+
throw new Error(`${H2A_MCP_CENTRAL_ENDPOINT_ENV} requires a current uid`);
|
|
44
|
+
}
|
|
45
|
+
return process.getuid();
|
|
46
|
+
}
|
|
47
|
+
/** Only explicit values opt into central routing; all other values preserve stdio. */
|
|
48
|
+
export function centralMcpEnabled(env = process.env) {
|
|
49
|
+
const value = env[H2A_MCP_CENTRAL_ENV];
|
|
50
|
+
return value === "1" || value === "true";
|
|
51
|
+
}
|
|
52
|
+
function isLoopbackHostname(hostname) {
|
|
53
|
+
return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Validate and canonicalize the one endpoint both launcher and clients use.
|
|
57
|
+
* Central MCP is plain Streamable HTTP; TLS termination belongs outside this
|
|
58
|
+
* local process, so https URLs are deliberately refused rather than half-served.
|
|
59
|
+
*/
|
|
60
|
+
export function parseCentralMcpEndpoint(value) {
|
|
61
|
+
if (!value || value.trim().length === 0) {
|
|
62
|
+
throw new Error(`${H2A_MCP_CENTRAL_ENDPOINT_ENV} must be a non-empty absolute http URL`);
|
|
63
|
+
}
|
|
64
|
+
let endpoint;
|
|
65
|
+
try {
|
|
66
|
+
endpoint = new URL(value);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error(`${H2A_MCP_CENTRAL_ENDPOINT_ENV} must be a non-empty absolute http URL`);
|
|
70
|
+
}
|
|
71
|
+
if (endpoint.protocol !== "http:" ||
|
|
72
|
+
!isLoopbackHostname(endpoint.hostname) ||
|
|
73
|
+
!endpoint.port ||
|
|
74
|
+
Number(endpoint.port) < 1 ||
|
|
75
|
+
Number(endpoint.port) > 65_535 ||
|
|
76
|
+
endpoint.username ||
|
|
77
|
+
endpoint.password ||
|
|
78
|
+
endpoint.search ||
|
|
79
|
+
endpoint.hash) {
|
|
80
|
+
throw new Error(`${H2A_MCP_CENTRAL_ENDPOINT_ENV} must be an absolute http URL with an explicit port`);
|
|
81
|
+
}
|
|
82
|
+
return endpoint.href;
|
|
83
|
+
}
|
|
84
|
+
/** Returns the central URL only when the explicit opt-in is enabled. */
|
|
85
|
+
export function centralMcpClientEndpoint(env = process.env, paths = {}) {
|
|
86
|
+
if (!centralMcpEnabled(env))
|
|
87
|
+
return undefined;
|
|
88
|
+
const endpoint = parseCentralMcpEndpoint(env[H2A_MCP_CENTRAL_ENDPOINT_ENV]);
|
|
89
|
+
const marker = readCentralClientMarker(centralMcpMarkerPath(paths));
|
|
90
|
+
if (marker.endpoint !== endpoint) {
|
|
91
|
+
throw new Error(`${H2A_MCP_CENTRAL_ENDPOINT_ENV} does not match the private central MCP marker endpoint`);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
command: "h2a",
|
|
95
|
+
args: [
|
|
96
|
+
"mcp-central-connect",
|
|
97
|
+
"--endpoint",
|
|
98
|
+
endpoint,
|
|
99
|
+
...(paths.runtimeBase ? ["--runtime-base", paths.runtimeBase] : [])
|
|
100
|
+
]
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Fixed UID-only marker path. `runtimeBase` is a test seam, never an env input.
|
|
105
|
+
*/
|
|
106
|
+
export function centralMcpMarkerPath(options = {}) {
|
|
107
|
+
const base = options.runtimeBase ?? join("/run/user", String(uid()));
|
|
108
|
+
return join(base, CENTRAL_RUNTIME_DIRECTORY, CENTRAL_MARKER_FILE);
|
|
109
|
+
}
|
|
110
|
+
function markerDirectory(options) {
|
|
111
|
+
return join(options.runtimeBase ?? join("/run/user", String(uid())), CENTRAL_RUNTIME_DIRECTORY);
|
|
112
|
+
}
|
|
113
|
+
function runtimeBase(options) {
|
|
114
|
+
return options.runtimeBase ?? join("/run/user", String(uid()));
|
|
115
|
+
}
|
|
116
|
+
function expectedMode(info, mode, label) {
|
|
117
|
+
if ((info.mode & 0o777) !== mode) {
|
|
118
|
+
throw new Error(`${label} must have mode ${mode.toString(8).padStart(4, "0")}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Read the token only from the same private marker clients rendezvous through. */
|
|
122
|
+
function readCentralClientMarker(path) {
|
|
123
|
+
const info = lstatSync(path);
|
|
124
|
+
if (!info.isFile())
|
|
125
|
+
throw new Error(`central MCP marker is not a regular file: ${path}`);
|
|
126
|
+
if (info.uid !== uid())
|
|
127
|
+
throw new Error(`central MCP marker is not owned by the current user: ${path}`);
|
|
128
|
+
expectedMode(info, 0o600, "central MCP marker");
|
|
129
|
+
let value;
|
|
130
|
+
try {
|
|
131
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new Error(`central MCP marker is malformed: ${path}`);
|
|
135
|
+
}
|
|
136
|
+
if (typeof value.endpoint !== "string" ||
|
|
137
|
+
typeof value.generation !== "string" ||
|
|
138
|
+
value.generation.length === 0 ||
|
|
139
|
+
typeof value.pid !== "number" ||
|
|
140
|
+
typeof value.startedAt !== "string" ||
|
|
141
|
+
typeof value.token !== "string" ||
|
|
142
|
+
value.token.length === 0) {
|
|
143
|
+
throw new Error(`central MCP marker is malformed: ${path}`);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return {
|
|
147
|
+
endpoint: parseCentralMcpEndpoint(value.endpoint),
|
|
148
|
+
generation: value.generation,
|
|
149
|
+
pid: value.pid,
|
|
150
|
+
startedAt: value.startedAt,
|
|
151
|
+
token: value.token
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new Error(`central MCP marker is malformed: ${path}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function parseCentralMcpBridgeMessages(body, contentType) {
|
|
159
|
+
if (body.trim().length === 0)
|
|
160
|
+
return [];
|
|
161
|
+
const values = [];
|
|
162
|
+
if (contentType?.includes("text/event-stream")) {
|
|
163
|
+
for (const event of body.split(/\r?\n\r?\n/)) {
|
|
164
|
+
const data = event
|
|
165
|
+
.split(/\r?\n/)
|
|
166
|
+
.filter((line) => line.startsWith("data:"))
|
|
167
|
+
.map((line) => line.slice("data:".length).trimStart())
|
|
168
|
+
.join("\n");
|
|
169
|
+
if (data)
|
|
170
|
+
values.push(JSON.parse(data));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
values.push(JSON.parse(body));
|
|
175
|
+
}
|
|
176
|
+
return values.flatMap((value) => Array.isArray(value) ? value : [value]);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Proxy one stdio MCP client to the current central Streamable-HTTP server.
|
|
180
|
+
* The bridge reads the private marker once at connect time, so a config never
|
|
181
|
+
* contains the bearer token and a newly launched bridge observes a restarted
|
|
182
|
+
* owner's current credential.
|
|
183
|
+
*/
|
|
184
|
+
export async function bridgeCentralMcpStdio(options) {
|
|
185
|
+
const endpoint = parseCentralMcpEndpoint(options.endpoint);
|
|
186
|
+
const marker = readCentralClientMarker(centralMcpMarkerPath(options));
|
|
187
|
+
if (marker.endpoint !== endpoint) {
|
|
188
|
+
throw new Error(`central MCP marker endpoint ${marker.endpoint} does not match requested endpoint ${endpoint}`);
|
|
189
|
+
}
|
|
190
|
+
const stdio = new StdioServerTransport(options.stdin, options.stdout);
|
|
191
|
+
let sessionId;
|
|
192
|
+
let stopped = false;
|
|
193
|
+
let failure;
|
|
194
|
+
let finish;
|
|
195
|
+
const finished = new Promise((resolve) => {
|
|
196
|
+
finish = resolve;
|
|
197
|
+
});
|
|
198
|
+
const close = async (error) => {
|
|
199
|
+
if (error !== undefined && failure === undefined) {
|
|
200
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
201
|
+
}
|
|
202
|
+
if (stopped)
|
|
203
|
+
return;
|
|
204
|
+
stopped = true;
|
|
205
|
+
try {
|
|
206
|
+
await stdio.close();
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
finish();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const forward = async (message) => {
|
|
213
|
+
if (stopped)
|
|
214
|
+
return;
|
|
215
|
+
const headers = {
|
|
216
|
+
authorization: `Bearer ${marker.token}`,
|
|
217
|
+
"content-type": "application/json",
|
|
218
|
+
accept: "application/json, text/event-stream"
|
|
219
|
+
};
|
|
220
|
+
if (sessionId)
|
|
221
|
+
headers["mcp-session-id"] = sessionId;
|
|
222
|
+
let response;
|
|
223
|
+
try {
|
|
224
|
+
response = await fetch(marker.endpoint, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers,
|
|
227
|
+
body: JSON.stringify(message)
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
await close(new Error(`central MCP bridge cannot reach ${marker.endpoint}: ${error.message}`));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
await close(new Error(`central MCP bridge request failed with HTTP ${response.status}`));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const returnedSessionId = response.headers.get("mcp-session-id");
|
|
239
|
+
if (returnedSessionId)
|
|
240
|
+
sessionId = returnedSessionId;
|
|
241
|
+
let messages;
|
|
242
|
+
try {
|
|
243
|
+
messages = parseCentralMcpBridgeMessages(await response.text(), response.headers.get("content-type"));
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
await close(new Error(`central MCP bridge received an invalid response: ${error.message}`));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
for (const next of messages) {
|
|
250
|
+
try {
|
|
251
|
+
await stdio.send(next);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
await close(error);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
stdio.onmessage = (message) => {
|
|
260
|
+
void forward(message).catch((error) => void close(error));
|
|
261
|
+
};
|
|
262
|
+
stdio.onerror = (error) => {
|
|
263
|
+
void close(error);
|
|
264
|
+
};
|
|
265
|
+
stdio.onclose = () => {
|
|
266
|
+
void close();
|
|
267
|
+
};
|
|
268
|
+
if (options.signal) {
|
|
269
|
+
if (options.signal.aborted)
|
|
270
|
+
void close();
|
|
271
|
+
else
|
|
272
|
+
options.signal.addEventListener("abort", () => void close(), { once: true });
|
|
273
|
+
}
|
|
274
|
+
await stdio.start();
|
|
275
|
+
await finished;
|
|
276
|
+
if (failure)
|
|
277
|
+
throw failure;
|
|
278
|
+
}
|
|
279
|
+
function lstatRequired(path, label) {
|
|
280
|
+
try {
|
|
281
|
+
return lstatSync(path);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (error.code === "ENOENT") {
|
|
285
|
+
throw new Error(`${H2A_MCP_CENTRAL_ENDPOINT_ENV} requires private runtime base ${path}`);
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function ensureMarkerDirectory(options) {
|
|
291
|
+
const ownership = await loadRuntimeOwnership();
|
|
292
|
+
const base = runtimeBase(options);
|
|
293
|
+
const baseInfo = lstatRequired(base, "central MCP runtime base");
|
|
294
|
+
if (!baseInfo.isDirectory()) {
|
|
295
|
+
throw new Error(`${H2A_MCP_CENTRAL_ENDPOINT_ENV} requires private runtime base ${base}`);
|
|
296
|
+
}
|
|
297
|
+
ownership.assertOwnedByCurrentUser(baseInfo, "central MCP runtime base");
|
|
298
|
+
if ((baseInfo.mode & 0o022) !== 0) {
|
|
299
|
+
throw new Error(`central MCP runtime base must not be group- or world-writable: ${base}`);
|
|
300
|
+
}
|
|
301
|
+
const directory = markerDirectory(options);
|
|
302
|
+
try {
|
|
303
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (error.code !== "EEXIST")
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
const directoryInfo = lstatSync(directory);
|
|
310
|
+
if (!directoryInfo.isDirectory()) {
|
|
311
|
+
throw new Error(`central MCP marker parent is not a directory: ${directory}`);
|
|
312
|
+
}
|
|
313
|
+
ownership.assertOwnedByCurrentUser(directoryInfo, "central MCP marker parent");
|
|
314
|
+
expectedMode(directoryInfo, 0o700, "central MCP marker parent");
|
|
315
|
+
}
|
|
316
|
+
async function assertMarkerOwnership(path) {
|
|
317
|
+
const ownership = await loadRuntimeOwnership();
|
|
318
|
+
const info = lstatSync(path);
|
|
319
|
+
if (!info.isFile())
|
|
320
|
+
throw new Error(`central MCP marker is not a regular file: ${path}`);
|
|
321
|
+
ownership.assertOwnedByCurrentUser(info, "central MCP marker");
|
|
322
|
+
expectedMode(info, 0o600, "central MCP marker");
|
|
323
|
+
return info;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* A protected malformed marker is stale state, not an absent marker. Preserve
|
|
327
|
+
* its filesystem identity so reclaim can verify it has not changed underneath
|
|
328
|
+
* us, just as the native socket publisher verifies dev/inode before removal.
|
|
329
|
+
*/
|
|
330
|
+
async function readMarker(path) {
|
|
331
|
+
try {
|
|
332
|
+
const info = await assertMarkerOwnership(path);
|
|
333
|
+
const identity = { dev: info.dev, ino: info.ino };
|
|
334
|
+
let value;
|
|
335
|
+
try {
|
|
336
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return { marker: undefined, identity };
|
|
340
|
+
}
|
|
341
|
+
if (typeof value.endpoint !== "string" ||
|
|
342
|
+
typeof value.generation !== "string" ||
|
|
343
|
+
value.generation.length === 0 ||
|
|
344
|
+
typeof value.pid !== "number" ||
|
|
345
|
+
typeof value.startedAt !== "string" ||
|
|
346
|
+
typeof value.token !== "string" ||
|
|
347
|
+
value.token.length === 0) {
|
|
348
|
+
return { marker: undefined, identity };
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
return {
|
|
352
|
+
marker: {
|
|
353
|
+
endpoint: parseCentralMcpEndpoint(value.endpoint),
|
|
354
|
+
generation: value.generation,
|
|
355
|
+
pid: value.pid,
|
|
356
|
+
// Informative only. It is intentionally never read by liveness or reclaim.
|
|
357
|
+
startedAt: value.startedAt,
|
|
358
|
+
token: value.token
|
|
359
|
+
},
|
|
360
|
+
identity
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return { marker: undefined, identity };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (error.code === "ENOENT")
|
|
369
|
+
return undefined;
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function markerBytes(marker) {
|
|
374
|
+
return `${JSON.stringify(marker)}\n`;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Publish a complete private marker without ever exposing an empty or partial
|
|
378
|
+
* target. `link(2)` provides the O_EXCL-equivalent compare-and-publish step:
|
|
379
|
+
* it either installs the already-written inode or fails with EEXIST.
|
|
380
|
+
*/
|
|
381
|
+
async function writeExclusiveMarker(path, marker, beforePublish) {
|
|
382
|
+
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
383
|
+
try {
|
|
384
|
+
writeFileSync(temporary, markerBytes(marker), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
385
|
+
if (beforePublish)
|
|
386
|
+
await beforePublish();
|
|
387
|
+
try {
|
|
388
|
+
linkSync(temporary, path);
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (error.code === "EEXIST")
|
|
393
|
+
return false;
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
try {
|
|
399
|
+
unlinkSync(temporary);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// A successful link leaves a second name for the same inode; cleanup is best effort.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Atomic replacement happens only after identity-based proof that the marker is
|
|
408
|
+
* dead and the protected file still has the dev/inode we inspected. This is the
|
|
409
|
+
* same check-before-remove pattern as `sameNativeTerminalSocket`.
|
|
410
|
+
*/
|
|
411
|
+
async function reclaimMarker(path, expected, registered, marker, beforeReplace, beforeExclusivePublish) {
|
|
412
|
+
const ownership = await loadRuntimeOwnership();
|
|
413
|
+
const lockPath = join(dirname(path), CENTRAL_RECLAIM_LOCK_FILE);
|
|
414
|
+
if (!await acquireReclaimLock(lockPath, marker, beforeExclusivePublish))
|
|
415
|
+
return false;
|
|
416
|
+
try {
|
|
417
|
+
let current;
|
|
418
|
+
try {
|
|
419
|
+
current = await assertMarkerOwnership(path);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (error.code === "ENOENT")
|
|
423
|
+
return false;
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
if (!ownership.sameNativeTerminalSocket(expected, { dev: current.dev, ino: current.ino })) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
if (registered) {
|
|
430
|
+
const liveness = await markerLiveness(registered);
|
|
431
|
+
if (liveness === "alive")
|
|
432
|
+
return false;
|
|
433
|
+
if (liveness === "ambiguous") {
|
|
434
|
+
throw new Error(`central MCP liveness for ${registered.endpoint} is ambiguous; refusing to reclaim a possibly-live server`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
await beforeReplace?.(marker);
|
|
438
|
+
const temporary = join(dirname(path), `.${CENTRAL_MARKER_FILE}.${randomUUID()}.tmp`);
|
|
439
|
+
// Do not use the endpoint or its host in the temporary name: this directory is
|
|
440
|
+
// fixed per uid and the marker is the sole rendezvous record.
|
|
441
|
+
try {
|
|
442
|
+
writeFileSync(temporary, markerBytes(marker), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
443
|
+
renameSync(temporary, path);
|
|
444
|
+
// Keep a post-rename verification as a second line of defense if a
|
|
445
|
+
// non-cooperating writer replaces the marker after our locked swap.
|
|
446
|
+
return (await readMarker(path))?.marker?.generation === marker.generation;
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
try {
|
|
450
|
+
unlinkSync(temporary);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// A successful rename already consumed the temp file; cleanup is best effort.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
await releaseReclaimLock(lockPath, marker.generation);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Coordinate reclaimers across the check-and-replace window. A live lock
|
|
463
|
+
* owner is itself a live pre-claim listener; an abandoned lock can be removed
|
|
464
|
+
* only when the same generation liveness check positively proves it dead.
|
|
465
|
+
*/
|
|
466
|
+
async function acquireReclaimLock(path, marker, beforeExclusivePublish) {
|
|
467
|
+
const ownership = await loadRuntimeOwnership();
|
|
468
|
+
for (;;) {
|
|
469
|
+
if (await writeExclusiveMarker(path, marker, beforeExclusivePublish))
|
|
470
|
+
return true;
|
|
471
|
+
const lock = await readMarker(path);
|
|
472
|
+
if (!lock)
|
|
473
|
+
continue;
|
|
474
|
+
if (!lock.marker) {
|
|
475
|
+
// A protected but malformed lock carries no endpoint/generation which
|
|
476
|
+
// could prove a live owner. Treat it like a malformed marker: remove it
|
|
477
|
+
// only if its dev/inode is still the one we inspected, then compete for
|
|
478
|
+
// a fresh O_EXCL lock on the next iteration.
|
|
479
|
+
let current;
|
|
480
|
+
try {
|
|
481
|
+
current = await assertMarkerOwnership(path);
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error.code === "ENOENT")
|
|
485
|
+
continue;
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
if (ownership.sameNativeTerminalSocket(lock.identity, { dev: current.dev, ino: current.ino })) {
|
|
489
|
+
unlinkSync(path);
|
|
490
|
+
}
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const liveness = await markerLiveness(lock.marker);
|
|
494
|
+
if (liveness === "alive") {
|
|
495
|
+
// The owner is about to publish its marker. Re-evaluate it rather than
|
|
496
|
+
// allowing a second contender to pass through the old stale marker.
|
|
497
|
+
await delay(10);
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
if (liveness === "ambiguous") {
|
|
501
|
+
throw new Error(`central MCP reclaim lock for ${lock.marker.endpoint} is ambiguous; refusing to remove it`);
|
|
502
|
+
}
|
|
503
|
+
let current;
|
|
504
|
+
try {
|
|
505
|
+
current = await assertMarkerOwnership(path);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (error.code === "ENOENT")
|
|
509
|
+
continue;
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
if (ownership.sameNativeTerminalSocket(lock.identity, { dev: current.dev, ino: current.ino })) {
|
|
513
|
+
unlinkSync(path);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function releaseReclaimLock(path, generation) {
|
|
518
|
+
const lock = await readMarker(path);
|
|
519
|
+
if (lock?.marker?.generation === generation)
|
|
520
|
+
unlinkSync(path);
|
|
521
|
+
}
|
|
522
|
+
function pingUrl(endpoint) {
|
|
523
|
+
return new URL(CENTRAL_PING_PATH, endpoint).href;
|
|
524
|
+
}
|
|
525
|
+
function errorHasCode(error, expectedCode) {
|
|
526
|
+
let current = error;
|
|
527
|
+
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
|
|
528
|
+
if (current.code === expectedCode)
|
|
529
|
+
return true;
|
|
530
|
+
current = current.cause;
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
async function centralPing(endpoint, timeoutMs) {
|
|
535
|
+
const controller = new AbortController();
|
|
536
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
537
|
+
try {
|
|
538
|
+
const response = await fetch(pingUrl(endpoint), { signal: controller.signal });
|
|
539
|
+
if (!response.ok)
|
|
540
|
+
return { kind: "dead" };
|
|
541
|
+
try {
|
|
542
|
+
const body = await response.json();
|
|
543
|
+
return typeof body.generation === "string" && body.generation.length > 0
|
|
544
|
+
? { kind: "generation", generation: body.generation }
|
|
545
|
+
: { kind: "dead" };
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// An HTTP peer answered but did not provide a registration generation.
|
|
549
|
+
return { kind: "dead" };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
// ECONNREFUSED is the only transport failure that positively proves no
|
|
554
|
+
// listener owns this registration. Timeouts and all other errors are not.
|
|
555
|
+
return errorHasCode(error, "ECONNREFUSED") ? { kind: "dead" } : { kind: "ambiguous" };
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
clearTimeout(timeout);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/** Identity, not PID, is the only liveness proof for a registered server. */
|
|
562
|
+
async function markerLiveness(marker) {
|
|
563
|
+
for (let attempt = 0; attempt < CENTRAL_LIVENESS_ATTEMPTS; attempt += 1) {
|
|
564
|
+
const result = await centralPing(marker.endpoint, CENTRAL_LIVENESS_TIMEOUT_MS + attempt * CENTRAL_LIVENESS_BACKOFF_MS);
|
|
565
|
+
if (result.kind === "generation") {
|
|
566
|
+
return result.generation === marker.generation ? "alive" : "dead";
|
|
567
|
+
}
|
|
568
|
+
if (result.kind === "dead")
|
|
569
|
+
return "dead";
|
|
570
|
+
if (attempt + 1 < CENTRAL_LIVENESS_ATTEMPTS) {
|
|
571
|
+
await delay(CENTRAL_LIVENESS_BACKOFF_MS * (attempt + 1));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return "ambiguous";
|
|
575
|
+
}
|
|
576
|
+
function newMarker(endpoint, generation = randomUUID()) {
|
|
577
|
+
return {
|
|
578
|
+
endpoint,
|
|
579
|
+
generation,
|
|
580
|
+
pid: process.pid,
|
|
581
|
+
startedAt: new Date().toISOString(),
|
|
582
|
+
token: randomUUID()
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Load-bearing order: ownership → read → identity liveness → claim/reclaim.
|
|
587
|
+
* A stale PID or an old startedAt cannot block a restart because neither enters
|
|
588
|
+
* the decision.
|
|
589
|
+
*/
|
|
590
|
+
async function claimCentralMarker(endpoint, paths, candidate, beforeReplace, beforeExclusivePublish) {
|
|
591
|
+
await ensureMarkerDirectory(paths);
|
|
592
|
+
const path = centralMcpMarkerPath(paths);
|
|
593
|
+
for (;;) {
|
|
594
|
+
const observation = await readMarker(path);
|
|
595
|
+
if (!observation) {
|
|
596
|
+
if (await writeExclusiveMarker(path, candidate, beforeExclusivePublish)) {
|
|
597
|
+
return { kind: "claimed", marker: candidate };
|
|
598
|
+
}
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (observation.marker) {
|
|
602
|
+
const liveness = await markerLiveness(observation.marker);
|
|
603
|
+
if (liveness === "alive") {
|
|
604
|
+
if (observation.marker.endpoint === endpoint)
|
|
605
|
+
return { kind: "reused", marker: observation.marker };
|
|
606
|
+
throw new Error(`a LIVE central MCP server is registered on ${observation.marker.endpoint}; this launcher requests ${endpoint}`);
|
|
607
|
+
}
|
|
608
|
+
if (liveness === "ambiguous") {
|
|
609
|
+
throw new Error(`central MCP liveness for ${observation.marker.endpoint} is ambiguous; refusing to reclaim a possibly-live server`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// A connection refusal, a responding non-generation peer, or a different
|
|
613
|
+
// generation is positive proof that THIS registration is dead. Reclaim
|
|
614
|
+
// even when endpoints are equal; never reclaim on ambiguous liveness.
|
|
615
|
+
if (await reclaimMarker(path, observation.identity, observation.marker, candidate, beforeReplace, beforeExclusivePublish)) {
|
|
616
|
+
return { kind: "claimed", marker: candidate };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function centralToolResult(server, name, args) {
|
|
621
|
+
const result = server.callTool(name, args);
|
|
622
|
+
if (isMcpTransportResult(result))
|
|
623
|
+
return result;
|
|
624
|
+
const isError = Boolean(result && typeof result === "object" && "error" in result);
|
|
625
|
+
return {
|
|
626
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
627
|
+
isError
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
function createCentralProtocolServer(mcp) {
|
|
631
|
+
const server = new Server({ name: "@sentropic/h2a", version: currentCliVersion() }, { capabilities: { tools: {} } });
|
|
632
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcp.listTools() }));
|
|
633
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => centralToolResult(mcp, request.params.name, request.params.arguments ?? {}));
|
|
634
|
+
return server;
|
|
635
|
+
}
|
|
636
|
+
function loopbackHostHeader(host) {
|
|
637
|
+
if (!host)
|
|
638
|
+
return false;
|
|
639
|
+
try {
|
|
640
|
+
return isLoopbackHostname(new URL(`http://${host}`).hostname);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function sameHttpOrigin(origin, host) {
|
|
647
|
+
if (!origin)
|
|
648
|
+
return true;
|
|
649
|
+
try {
|
|
650
|
+
const requestOrigin = new URL(`http://${host}`).origin;
|
|
651
|
+
return new URL(origin).origin === requestOrigin;
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function createCentralApp(mcp, endpoint, generation, token) {
|
|
658
|
+
const app = new Hono();
|
|
659
|
+
const sessions = new Map();
|
|
660
|
+
app.get(CENTRAL_PING_PATH, (context) => context.json({ generation }));
|
|
661
|
+
app.all(new URL(endpoint).pathname, async (context) => {
|
|
662
|
+
const host = context.req.header("host");
|
|
663
|
+
if (!host || !loopbackHostHeader(host)) {
|
|
664
|
+
return context.json({ error: "central MCP request origin is not allowed" }, 403);
|
|
665
|
+
}
|
|
666
|
+
if (!sameHttpOrigin(context.req.header("origin"), host)) {
|
|
667
|
+
return context.json({ error: "central MCP request origin is not allowed" }, 403);
|
|
668
|
+
}
|
|
669
|
+
if (context.req.header("authorization") !== `Bearer ${token}`) {
|
|
670
|
+
context.header("www-authenticate", "Bearer");
|
|
671
|
+
return context.json({ error: "central MCP authorization is required" }, 401);
|
|
672
|
+
}
|
|
673
|
+
const centralMcp = mcp();
|
|
674
|
+
if (!centralMcp)
|
|
675
|
+
return context.json({ error: "central MCP is starting" }, 503);
|
|
676
|
+
const requestedSessionId = context.req.header("mcp-session-id");
|
|
677
|
+
let transport = requestedSessionId ? sessions.get(requestedSessionId) : undefined;
|
|
678
|
+
if (!transport) {
|
|
679
|
+
let created;
|
|
680
|
+
transport = new StreamableHTTPTransport({
|
|
681
|
+
enableJsonResponse: true,
|
|
682
|
+
sessionIdGenerator: randomUUID,
|
|
683
|
+
onsessioninitialized: (sessionId) => {
|
|
684
|
+
if (created)
|
|
685
|
+
sessions.set(sessionId, created);
|
|
686
|
+
},
|
|
687
|
+
onsessionclosed: (sessionId) => {
|
|
688
|
+
sessions.delete(sessionId);
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
created = transport;
|
|
692
|
+
await createCentralProtocolServer(centralMcp).connect(transport);
|
|
693
|
+
}
|
|
694
|
+
const response = await transport.handleRequest(context);
|
|
695
|
+
return response ?? context.body(null, 202);
|
|
696
|
+
});
|
|
697
|
+
return app;
|
|
698
|
+
}
|
|
699
|
+
function addressIsInUse(error) {
|
|
700
|
+
return Boolean(error && typeof error === "object" && error.code === "EADDRINUSE");
|
|
701
|
+
}
|
|
702
|
+
function delay(milliseconds) {
|
|
703
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* A same-endpoint contender cannot bind a second listener. If it found a
|
|
707
|
+
* central ping responder, wait briefly for the listener's owner to publish its
|
|
708
|
+
* matching marker, then reuse that owner without writing any marker itself.
|
|
709
|
+
*/
|
|
710
|
+
async function markedCentralListener(endpoint, markerPath) {
|
|
711
|
+
const deadline = Date.now() + CENTRAL_LIVENESS_TIMEOUT_MS * CENTRAL_LIVENESS_ATTEMPTS;
|
|
712
|
+
for (;;) {
|
|
713
|
+
const ping = await centralPing(endpoint, CENTRAL_LIVENESS_TIMEOUT_MS);
|
|
714
|
+
if (ping.kind !== "generation")
|
|
715
|
+
return undefined;
|
|
716
|
+
const observation = await readMarker(markerPath);
|
|
717
|
+
if (observation?.marker?.endpoint === endpoint &&
|
|
718
|
+
observation.marker.generation === ping.generation) {
|
|
719
|
+
return observation.marker;
|
|
720
|
+
}
|
|
721
|
+
if (Date.now() >= deadline)
|
|
722
|
+
return undefined;
|
|
723
|
+
await delay(10);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async function closeHttpServer(httpServer, mcp) {
|
|
727
|
+
try {
|
|
728
|
+
await new Promise((resolve, reject) => {
|
|
729
|
+
httpServer.close((error) => error ? reject(error) : resolve());
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
mcp?.sessions.closeAll("closed");
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Start exactly one full-surface MCP server at the explicit endpoint.
|
|
738
|
+
* The endpoint begins answering its generation ping before that generation is
|
|
739
|
+
* recorded in the marker, so a contender never mistakes a starting owner for
|
|
740
|
+
* a dead registration.
|
|
741
|
+
*/
|
|
742
|
+
export async function startCentralMcpServer(options) {
|
|
743
|
+
const env = options.env ?? process.env;
|
|
744
|
+
const endpoint = parseCentralMcpEndpoint(env[H2A_MCP_CENTRAL_ENDPOINT_ENV]);
|
|
745
|
+
const paths = options.runtimeBase ? { runtimeBase: options.runtimeBase } : {};
|
|
746
|
+
const markerPath = centralMcpMarkerPath(paths);
|
|
747
|
+
const marker = newMarker(endpoint, randomUUID());
|
|
748
|
+
const endpointUrl = new URL(endpoint);
|
|
749
|
+
const hostname = endpointUrl.hostname.startsWith("[")
|
|
750
|
+
? endpointUrl.hostname.slice(1, -1)
|
|
751
|
+
: endpointUrl.hostname;
|
|
752
|
+
let mcp;
|
|
753
|
+
let httpServer;
|
|
754
|
+
try {
|
|
755
|
+
httpServer = serve({
|
|
756
|
+
fetch: createCentralApp(() => mcp, endpoint, marker.generation, marker.token).fetch,
|
|
757
|
+
hostname,
|
|
758
|
+
port: Number(endpointUrl.port)
|
|
759
|
+
});
|
|
760
|
+
if (!httpServer.listening)
|
|
761
|
+
await once(httpServer, "listening");
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
try {
|
|
765
|
+
if (addressIsInUse(error)) {
|
|
766
|
+
const existing = await markedCentralListener(endpoint, markerPath);
|
|
767
|
+
if (existing) {
|
|
768
|
+
return {
|
|
769
|
+
kind: "reused",
|
|
770
|
+
endpoint: existing.endpoint,
|
|
771
|
+
generation: existing.generation,
|
|
772
|
+
markerPath
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
finally {
|
|
778
|
+
mcp?.sessions.closeAll("closed");
|
|
779
|
+
}
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
let claim;
|
|
783
|
+
try {
|
|
784
|
+
claim = await claimCentralMarker(endpoint, paths, marker, options.beforeMarkerReclaimReplace, options.beforeExclusiveMarkerPublish);
|
|
785
|
+
if (claim.kind === "reused") {
|
|
786
|
+
await closeHttpServer(httpServer, mcp);
|
|
787
|
+
return {
|
|
788
|
+
kind: "reused",
|
|
789
|
+
endpoint: claim.marker.endpoint,
|
|
790
|
+
generation: claim.marker.generation,
|
|
791
|
+
markerPath
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
mcp = createMcpServer({ root: options.root, workspaceRoot: process.cwd() });
|
|
795
|
+
await options.afterMarkerClaim?.();
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
try {
|
|
799
|
+
await closeHttpServer(httpServer, mcp);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// Preserve the marker conflict or claim error after attempting cleanup.
|
|
803
|
+
}
|
|
804
|
+
if (claim?.kind === "claimed") {
|
|
805
|
+
try {
|
|
806
|
+
const current = await readMarker(markerPath);
|
|
807
|
+
if (current?.marker?.generation === marker.generation)
|
|
808
|
+
unlinkSync(markerPath);
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// Never remove a marker we cannot prove is still ours.
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
let stopped = false;
|
|
817
|
+
return {
|
|
818
|
+
kind: "started",
|
|
819
|
+
endpoint,
|
|
820
|
+
generation: marker.generation,
|
|
821
|
+
markerPath,
|
|
822
|
+
async stop() {
|
|
823
|
+
if (stopped)
|
|
824
|
+
return;
|
|
825
|
+
stopped = true;
|
|
826
|
+
await closeHttpServer(httpServer, mcp);
|
|
827
|
+
try {
|
|
828
|
+
const current = await readMarker(markerPath);
|
|
829
|
+
if (current?.marker?.generation === marker.generation)
|
|
830
|
+
unlinkSync(markerPath);
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// A successor marker belongs to another server; never remove it.
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
//# sourceMappingURL=mcp-central.js.map
|