@sentropic/h2a 0.97.2 → 0.97.4
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.js +13 -0
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +94 -37
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +2 -0
- package/dist/mcp.js.map +1 -1
- package/dist/runtime/identity/bindings.d.ts +33 -0
- package/dist/runtime/identity/bindings.d.ts.map +1 -1
- package/dist/runtime/identity/bindings.js +65 -2
- package/dist/runtime/identity/bindings.js.map +1 -1
- package/dist/runtime/identity/index.d.ts +4 -4
- package/dist/runtime/identity/index.d.ts.map +1 -1
- package/dist/runtime/identity/index.js +2 -2
- package/dist/runtime/identity/index.js.map +1 -1
- package/dist/runtime/identity/live.d.ts +63 -0
- package/dist/runtime/identity/live.d.ts.map +1 -1
- package/dist/runtime/identity/live.js +346 -65
- package/dist/runtime/identity/live.js.map +1 -1
- package/dist/runtime/identity/worker.d.ts +19 -0
- package/dist/runtime/identity/worker.d.ts.map +1 -0
- package/dist/runtime/identity/worker.js +189 -0
- package/dist/runtime/identity/worker.js.map +1 -0
- package/dist/runtime/local-files/locks.d.ts +34 -0
- package/dist/runtime/local-files/locks.d.ts.map +1 -1
- package/dist/runtime/local-files/locks.js +55 -2
- package/dist/runtime/local-files/locks.js.map +1 -1
- package/dist/runtime/local-files/store.d.ts +21 -0
- package/dist/runtime/local-files/store.d.ts.map +1 -1
- package/dist/runtime/local-files/store.js +79 -10
- package/dist/runtime/local-files/store.js.map +1 -1
- package/dist/runtime/mcp/discovery-pagination.d.ts +73 -0
- package/dist/runtime/mcp/discovery-pagination.d.ts.map +1 -0
- package/dist/runtime/mcp/discovery-pagination.js +446 -0
- package/dist/runtime/mcp/discovery-pagination.js.map +1 -0
- package/dist/runtime/mcp/frame-budget.d.ts +149 -0
- package/dist/runtime/mcp/frame-budget.d.ts.map +1 -0
- package/dist/runtime/mcp/frame-budget.js +275 -0
- package/dist/runtime/mcp/frame-budget.js.map +1 -0
- package/dist/runtime/mcp/handlers.d.ts +14 -3
- package/dist/runtime/mcp/handlers.d.ts.map +1 -1
- package/dist/runtime/mcp/handlers.js +22 -1
- package/dist/runtime/mcp/handlers.js.map +1 -1
- package/dist/runtime/mcp/identity-state.d.ts +131 -0
- package/dist/runtime/mcp/identity-state.d.ts.map +1 -0
- package/dist/runtime/mcp/identity-state.js +288 -0
- package/dist/runtime/mcp/identity-state.js.map +1 -0
- package/dist/runtime/mcp/index.d.ts +2 -0
- package/dist/runtime/mcp/index.d.ts.map +1 -1
- package/dist/runtime/mcp/index.js +2 -0
- package/dist/runtime/mcp/index.js.map +1 -1
- package/dist/runtime/mcp/notifications.d.ts +13 -1
- package/dist/runtime/mcp/notifications.d.ts.map +1 -1
- package/dist/runtime/mcp/notifications.js +36 -20
- package/dist/runtime/mcp/notifications.js.map +1 -1
- package/dist/runtime/mcp/payload-store.d.ts +67 -0
- package/dist/runtime/mcp/payload-store.d.ts.map +1 -0
- package/dist/runtime/mcp/payload-store.js +242 -0
- package/dist/runtime/mcp/payload-store.js.map +1 -0
- package/dist/runtime/mcp/phase-trace.d.ts +98 -0
- package/dist/runtime/mcp/phase-trace.d.ts.map +1 -0
- package/dist/runtime/mcp/phase-trace.js +179 -0
- package/dist/runtime/mcp/phase-trace.js.map +1 -0
- package/dist/runtime/mcp/server.d.ts +36 -1
- package/dist/runtime/mcp/server.d.ts.map +1 -1
- package/dist/runtime/mcp/server.js +162 -5
- package/dist/runtime/mcp/server.js.map +1 -1
- package/dist/runtime/mcp/sessions.d.ts +12 -2
- package/dist/runtime/mcp/sessions.d.ts.map +1 -1
- package/dist/runtime/mcp/sessions.js +15 -3
- package/dist/runtime/mcp/sessions.js.map +1 -1
- package/dist/runtime/mcp/stdio.d.ts +21 -0
- package/dist/runtime/mcp/stdio.d.ts.map +1 -1
- package/dist/runtime/mcp/stdio.js +378 -98
- package/dist/runtime/mcp/stdio.js.map +1 -1
- package/dist/runtime/mcp/tools.d.ts.map +1 -1
- package/dist/runtime/mcp/tools.js +47 -4
- package/dist/runtime/mcp/tools.js.map +1 -1
- package/dist/runtime/mcp-central.d.ts.map +1 -1
- package/dist/runtime/mcp-central.js +17 -7
- package/dist/runtime/mcp-central.js.map +1 -1
- package/dist/runtime/mcp-http/hosted-mcp-server.d.ts.map +1 -1
- package/dist/runtime/mcp-http/hosted-mcp-server.js +16 -6
- package/dist/runtime/mcp-http/hosted-mcp-server.js.map +1 -1
- package/dist/runtime/mcp-http/readonly-allowlist.d.ts.map +1 -1
- package/dist/runtime/mcp-http/readonly-allowlist.js +6 -0
- package/dist/runtime/mcp-http/readonly-allowlist.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { linkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { linkSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { createInboxWakeHandler } from "../drive/inbox-wake.js";
|
|
6
6
|
import { detectLocalLaunchContext, detectTmuxLaunchContext } from "../drive/index.js";
|
|
7
7
|
import { createLocalStore } from "../local-files/index.js";
|
|
8
8
|
import { reapDeadInstancePresence } from "../local-files/presence.js";
|
|
9
9
|
import { agentVersion } from "../version/agent-version.js";
|
|
10
|
+
import { currentCliVersion } from "../upgrade/index.js";
|
|
11
|
+
import { getActiveMcpTrace } from "./phase-trace.js";
|
|
10
12
|
import { createMcpServer, isMcpTransportResult } from "./server.js";
|
|
13
|
+
import { createIdentityController } from "./identity-state.js";
|
|
14
|
+
import { boundNotificationFrame, boundResponseFrame, buildInvalidRequestId, encodeFrame, isAcceptableRequestId } from "./frame-budget.js";
|
|
11
15
|
function envInt(name) {
|
|
12
16
|
const raw = process.env[name];
|
|
13
17
|
if (!raw)
|
|
@@ -17,7 +21,23 @@ function envInt(name) {
|
|
|
17
21
|
}
|
|
18
22
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
19
23
|
const SERVER_NAME = "@sentropic/h2a";
|
|
20
|
-
|
|
24
|
+
// The MCP `serverInfo.version` MUST be the real package version, not a frozen
|
|
25
|
+
// literal — a stale `0.1.1` made `initialize` disagree with the plugin manifest
|
|
26
|
+
// and `h2a --version`, defeating the version-drift diagnosis (#275). Resolved
|
|
27
|
+
// from package.json via `currentCliVersion()` and cached (initialize is rare,
|
|
28
|
+
// but the read is trivial and total — it falls back to "0.0.0" on any error).
|
|
29
|
+
let cachedServerVersion;
|
|
30
|
+
function serverVersion() {
|
|
31
|
+
if (cachedServerVersion === undefined) {
|
|
32
|
+
try {
|
|
33
|
+
cachedServerVersion = currentCliVersion();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
cachedServerVersion = "0.0.0";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return cachedServerVersion;
|
|
40
|
+
}
|
|
21
41
|
function currentTmuxSessionForSidecar() {
|
|
22
42
|
const pane = process.env.TMUX_PANE;
|
|
23
43
|
if (!pane || !/^%\d+$/.test(pane))
|
|
@@ -67,9 +87,6 @@ function publishReadinessAck(readiness, sessionId) {
|
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
89
|
}
|
|
70
|
-
function writeResponse(stdout, response) {
|
|
71
|
-
stdout.write(`${JSON.stringify(response)}\n`);
|
|
72
|
-
}
|
|
73
90
|
function errorResponse(id, code, message, data) {
|
|
74
91
|
const error = { code, message };
|
|
75
92
|
if (data !== undefined)
|
|
@@ -83,7 +100,7 @@ function handleMethod(server, method, params) {
|
|
|
83
100
|
if (method === "initialize") {
|
|
84
101
|
return {
|
|
85
102
|
protocolVersion: PROTOCOL_VERSION,
|
|
86
|
-
serverInfo: { name: SERVER_NAME, version:
|
|
103
|
+
serverInfo: { name: SERVER_NAME, version: serverVersion() },
|
|
87
104
|
capabilities: { tools: {} }
|
|
88
105
|
};
|
|
89
106
|
}
|
|
@@ -126,9 +143,27 @@ class MethodNotFoundError extends Error {
|
|
|
126
143
|
*/
|
|
127
144
|
export function runMcpStdio(options) {
|
|
128
145
|
const { root, stdin, stdout, stderr } = options;
|
|
129
|
-
|
|
146
|
+
// L0 trace: the ambient per-attempt trace installed by bin.ts. Undefined in
|
|
147
|
+
// unit tests and normal CLI verbs → every call is a no-op. It only ever
|
|
148
|
+
// writes to stderr, never to the JSON-RPC stdout stream.
|
|
149
|
+
const trace = getActiveMcpTrace();
|
|
150
|
+
// Only the FIRST tool response is the calibration milestone; the rest are
|
|
151
|
+
// still measured but tagged distinctly.
|
|
152
|
+
let firstToolCallTraced = false;
|
|
153
|
+
if (options.readiness && !options.autoOpen && !options.identityRequest) {
|
|
130
154
|
throw new Error("structured readiness requires successful auto-open");
|
|
131
155
|
}
|
|
156
|
+
if (options.autoOpen && options.identityRequest) {
|
|
157
|
+
throw new Error("autoOpen and identityRequest are mutually exclusive");
|
|
158
|
+
}
|
|
159
|
+
// L2: the live signing identity, set only once identity is really bound. The
|
|
160
|
+
// server reads it on each h2a_send via `getSendContext`, so a signer that only
|
|
161
|
+
// becomes available after asynchronous activation is picked up with no rebuild.
|
|
162
|
+
let liveSendContext = options.sendContext;
|
|
163
|
+
// Forward reference to the identity controller; assigned below when the
|
|
164
|
+
// deferred identity path is used. The server guards signed/mutating tools by
|
|
165
|
+
// reading its state on every call.
|
|
166
|
+
let identityController;
|
|
132
167
|
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? envInt("H2A_HEARTBEAT_INTERVAL_MS");
|
|
133
168
|
const notifyIntervalMs = options.notifyIntervalMs ??
|
|
134
169
|
envInt("H2A_NOTIFY_INTERVAL_MS") ??
|
|
@@ -137,11 +172,25 @@ export function runMcpStdio(options) {
|
|
|
137
172
|
// The stdio transport carries live agent sessions; enable autoHeartbeat so
|
|
138
173
|
// the presence file stays fresh while this mcp-serve process is alive.
|
|
139
174
|
let delegation;
|
|
175
|
+
// L1: forward reference — the notification sink is installed on the server
|
|
176
|
+
// below, but the bounded emitter needs `server.frameBudget`/`payloadStore`,
|
|
177
|
+
// which only exist after creation. Ticks are unref'd and interval-driven, so
|
|
178
|
+
// the real emitter is always assigned before the first tick fires.
|
|
179
|
+
let emitNotification = () => ({ accepted: false });
|
|
140
180
|
const server = createMcpServer({
|
|
141
181
|
root,
|
|
142
182
|
workspaceRoot: options.workspaceRoot ?? process.cwd(),
|
|
143
183
|
...(options.runExecutor ? { runExecutor: options.runExecutor } : {}),
|
|
144
184
|
...(options.sendContext ? { sendContext: options.sendContext } : {}),
|
|
185
|
+
// L2: with a deferred identity, the store must not write on boot so
|
|
186
|
+
// initialize/tools-list/status answer on a read-only or not-yet-created root.
|
|
187
|
+
...(options.identityRequest ? { storeInitialize: false } : {}),
|
|
188
|
+
// L2: the live signer is read on each call so post-activation availability
|
|
189
|
+
// (or its absence after a failure) is always reflected honestly.
|
|
190
|
+
getSendContext: () => liveSendContext,
|
|
191
|
+
get identity() {
|
|
192
|
+
return identityController;
|
|
193
|
+
},
|
|
145
194
|
delegationContext: () => delegation,
|
|
146
195
|
sessions: {
|
|
147
196
|
autoHeartbeat: true,
|
|
@@ -150,19 +199,112 @@ export function runMcpStdio(options) {
|
|
|
150
199
|
},
|
|
151
200
|
notifications: {
|
|
152
201
|
...(notifyIntervalMs !== undefined ? { intervalMs: notifyIntervalMs } : {}),
|
|
153
|
-
sink: (notification) => {
|
|
154
|
-
|
|
155
|
-
|
|
202
|
+
sink: (notification) => emitNotification(notification, {
|
|
203
|
+
method: notification.method,
|
|
204
|
+
topic: typeof notification.params?.topic === "string"
|
|
205
|
+
? notification.params.topic
|
|
206
|
+
: undefined
|
|
207
|
+
})
|
|
156
208
|
}
|
|
157
209
|
});
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// L1: the SINGLE bounded output writer. Every frame — success, error, parse
|
|
212
|
+
// error, initialize, tools/list, a preformatted Track result, AND every pushed
|
|
213
|
+
// notification — is bounded to the frame budget here; there is NO direct write
|
|
214
|
+
// to `stdout` anywhere else. Writes are ordered and drain-aware (backpressure),
|
|
215
|
+
// and the queue is byte-bounded so a slow reader cannot grow memory unbounded.
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
const frameBudget = server.frameBudget;
|
|
218
|
+
const recover = (intactJson) => {
|
|
219
|
+
try {
|
|
220
|
+
return server.payloadStore.persistOutput(Buffer.from(intactJson, "utf8"));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const MAX_QUEUE_BYTES = 8 * 1024 * 1024;
|
|
227
|
+
let writeChain = Promise.resolve();
|
|
228
|
+
let queuedBytes = 0;
|
|
229
|
+
function enqueueLine(line) {
|
|
230
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
231
|
+
queuedBytes += bytes;
|
|
232
|
+
writeChain = writeChain
|
|
233
|
+
.then(() => new Promise((resolve, reject) => {
|
|
234
|
+
const flushed = stdout.write(line, (err) => {
|
|
235
|
+
if (err)
|
|
236
|
+
reject(err);
|
|
237
|
+
});
|
|
238
|
+
if (flushed)
|
|
239
|
+
resolve();
|
|
240
|
+
else
|
|
241
|
+
stdout.once("drain", resolve);
|
|
242
|
+
}))
|
|
243
|
+
.then(() => {
|
|
244
|
+
queuedBytes -= bytes;
|
|
245
|
+
}, (err) => {
|
|
246
|
+
queuedBytes -= bytes;
|
|
247
|
+
throw err;
|
|
248
|
+
});
|
|
249
|
+
writeChain.catch((err) => {
|
|
250
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
+
try {
|
|
252
|
+
stderr.write(`h2a mcp-serve: stdout write error: ${message}\n`);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* stderr is diagnostic-only; a broken diagnostic sink is not fatal */
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** Bound and enqueue a JSON-RPC response; returns the frame ACTUALLY emitted. */
|
|
260
|
+
function emitResponse(id, responseObject) {
|
|
261
|
+
const frame = boundResponseFrame(id, responseObject, frameBudget, recover);
|
|
262
|
+
enqueueLine(frame.line);
|
|
263
|
+
return frame;
|
|
264
|
+
}
|
|
265
|
+
emitNotification = (notification, meta) => {
|
|
266
|
+
if (queuedBytes > MAX_QUEUE_BYTES) {
|
|
267
|
+
// Queue bound: drop rather than grow memory unbounded. A response is never
|
|
268
|
+
// dropped; a notification is retried by the dispatcher (snapshot held).
|
|
269
|
+
try {
|
|
270
|
+
stderr.write(`h2a mcp-serve: notification queue full; dropped ${meta.method}\n`);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* diagnostic only */
|
|
274
|
+
}
|
|
275
|
+
return { accepted: false };
|
|
276
|
+
}
|
|
277
|
+
const outcome = boundNotificationFrame(notification, meta, frameBudget, recover);
|
|
278
|
+
if (!outcome) {
|
|
279
|
+
try {
|
|
280
|
+
stderr.write(`h2a mcp-serve: dropped unserializable notification ${meta.method}\n`);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* diagnostic only */
|
|
284
|
+
}
|
|
285
|
+
return { accepted: false };
|
|
286
|
+
}
|
|
287
|
+
enqueueLine(outcome.frame.line);
|
|
288
|
+
return { accepted: outcome.accepted };
|
|
289
|
+
};
|
|
158
290
|
// DEC-052: start the periodic diff scan so subscribed sessions receive
|
|
159
291
|
// pushed presence/inbox/negotiation notifications.
|
|
160
292
|
server.notifications.start();
|
|
161
293
|
let didShutdown = false;
|
|
162
|
-
function shutdown() {
|
|
294
|
+
function shutdown(reason = "transport_closed") {
|
|
163
295
|
if (didShutdown)
|
|
164
296
|
return;
|
|
165
297
|
didShutdown = true;
|
|
298
|
+
try {
|
|
299
|
+
// L2: the transport is gone — cancel any in-flight identity resolution so
|
|
300
|
+
// no NEW transaction starts and no late activation/ACK can follow. A
|
|
301
|
+
// transaction already entered under a lock finishes cooperatively; a
|
|
302
|
+
// blocked worker is logged, never SIGKILL'd (it may hold a fence).
|
|
303
|
+
identityController?.cancel(reason);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* best effort */
|
|
307
|
+
}
|
|
166
308
|
try {
|
|
167
309
|
server.notifications.stop();
|
|
168
310
|
server.sessions.closeAll("closed");
|
|
@@ -175,62 +317,84 @@ export function runMcpStdio(options) {
|
|
|
175
317
|
// WP-F: the auto-opened session id, so the line loop can mark MCP activity on
|
|
176
318
|
// it (presence-honesty — proof the host→server channel is carrying traffic).
|
|
177
319
|
let autoOpenedSessionId;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
...((() => {
|
|
195
|
-
const lc = detectLocalLaunchContext(process.env, undefined, `h2a mcp-serve --host ${options.autoOpen.host ?? ""}`.trim());
|
|
196
|
-
return lc ? { launchContext: lc } : {};
|
|
197
|
-
})()),
|
|
198
|
-
interests: {
|
|
199
|
-
scopes: [...(options.autoOpen.scopes ?? ["scope:default"])],
|
|
200
|
-
negotiations: []
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
autoOpenedSessionId = opened.sessionId;
|
|
204
|
-
// Spec 2026-07-25-h2a-lane-addressing §D1b: follow the host-native title
|
|
205
|
-
// for the life of the session, so a rename converges into presence within
|
|
206
|
-
// one heartbeat instead of staying stale until the host reconnects.
|
|
207
|
-
// Absent when the operator passed an explicit `--name`.
|
|
208
|
-
if (options.autoOpen.refreshDisplayName) {
|
|
209
|
-
server.sessions.setDisplayNameResolver(opened.sessionId, options.autoOpen.refreshDisplayName);
|
|
210
|
-
}
|
|
211
|
-
const delegatorTmuxSession = currentTmuxSessionForSidecar();
|
|
212
|
-
if (delegatorTmuxSession && options.autoOpen.delegationEligible === true) {
|
|
213
|
-
delegation = {
|
|
214
|
-
origin: "mcp:h2a_run",
|
|
215
|
-
delegatorInstance: options.autoOpen.instance,
|
|
216
|
-
delegatorTmuxSession,
|
|
217
|
-
};
|
|
218
|
-
recordTmuxOwner(delegatorTmuxSession, options.autoOpen.instance);
|
|
219
|
-
}
|
|
220
|
-
stderr.write(`h2a mcp-serve: auto-opened session for ${options.autoOpen.instance}\n`);
|
|
221
|
-
// Reap the false-live presence left by a previous connection of THIS
|
|
222
|
-
// agent that the host dropped without signalling (process lingered,
|
|
223
|
-
// blind heartbeat kept presence "live"). Best-effort, same-instance only.
|
|
224
|
-
try {
|
|
225
|
-
const reaped = reapDeadInstancePresence(root, options.autoOpen.instance, opened.sessionId);
|
|
226
|
-
if (reaped.length > 0) {
|
|
227
|
-
stderr.write(`h2a mcp-serve: reaped ${reaped.length} stale presence file(s) for ${options.autoOpen.instance}\n`);
|
|
228
|
-
}
|
|
320
|
+
function openAutoOpenSession(cfg) {
|
|
321
|
+
const opened = server.sessions.open({
|
|
322
|
+
instance: cfg.instance,
|
|
323
|
+
...(cfg.host !== undefined ? { host: cfg.host } : {}),
|
|
324
|
+
...(cfg.workspace !== undefined ? { workspace: cfg.workspace } : {}),
|
|
325
|
+
...(cfg.name !== undefined ? { name: cfg.name } : {}),
|
|
326
|
+
version: agentVersion(cfg.host),
|
|
327
|
+
// Auto-capture our owning local terminal (native session or inherited
|
|
328
|
+
// tmux pane) so loop scheduling has an explicit wake target.
|
|
329
|
+
...((() => {
|
|
330
|
+
const lc = detectLocalLaunchContext(process.env, undefined, `h2a mcp-serve --host ${cfg.host ?? ""}`.trim());
|
|
331
|
+
return lc ? { launchContext: lc } : {};
|
|
332
|
+
})()),
|
|
333
|
+
interests: {
|
|
334
|
+
scopes: [...(cfg.scopes ?? ["scope:default"])],
|
|
335
|
+
negotiations: []
|
|
229
336
|
}
|
|
230
|
-
|
|
231
|
-
|
|
337
|
+
});
|
|
338
|
+
trace?.phase("session_open");
|
|
339
|
+
// Spec 2026-07-25-h2a-lane-addressing §D1b: follow the host-native title
|
|
340
|
+
// for the life of the session so a rename converges into presence within
|
|
341
|
+
// one heartbeat. Absent when the operator passed an explicit `--name`.
|
|
342
|
+
if (cfg.refreshDisplayName) {
|
|
343
|
+
server.sessions.setDisplayNameResolver(opened.sessionId, cfg.refreshDisplayName);
|
|
344
|
+
}
|
|
345
|
+
const delegatorTmuxSession = currentTmuxSessionForSidecar();
|
|
346
|
+
if (delegatorTmuxSession && cfg.delegationEligible === true) {
|
|
347
|
+
delegation = {
|
|
348
|
+
origin: "mcp:h2a_run",
|
|
349
|
+
delegatorInstance: cfg.instance,
|
|
350
|
+
delegatorTmuxSession
|
|
351
|
+
};
|
|
352
|
+
recordTmuxOwner(delegatorTmuxSession, cfg.instance);
|
|
353
|
+
}
|
|
354
|
+
stderr.write(`h2a mcp-serve: auto-opened session for ${cfg.instance}\n`);
|
|
355
|
+
// Reap the false-live presence left by a previous connection of THIS agent
|
|
356
|
+
// that the host dropped without signalling. Best-effort, same-instance only.
|
|
357
|
+
try {
|
|
358
|
+
const reaped = reapDeadInstancePresence(root, cfg.instance, opened.sessionId);
|
|
359
|
+
if (reaped.length > 0) {
|
|
360
|
+
stderr.write(`h2a mcp-serve: reaped ${reaped.length} stale presence file(s) for ${cfg.instance}\n`);
|
|
232
361
|
}
|
|
233
362
|
}
|
|
363
|
+
catch {
|
|
364
|
+
// best-effort
|
|
365
|
+
}
|
|
366
|
+
return opened.sessionId;
|
|
367
|
+
}
|
|
368
|
+
// EVO-1 wake (bug #3): wake the idle host when a new inbox envelope arrives.
|
|
369
|
+
function armInboxWake(cfg, wakeCfg) {
|
|
370
|
+
const wakeInstance = cfg.instance;
|
|
371
|
+
const wakeStore = createLocalStore({ root });
|
|
372
|
+
const wake = createInboxWakeHandler({
|
|
373
|
+
instance: wakeInstance,
|
|
374
|
+
readInbox: () => wakeStore.readInbox(wakeInstance),
|
|
375
|
+
privateKeyPem: wakeCfg.privateKeyPem,
|
|
376
|
+
driver: wakeCfg.driver,
|
|
377
|
+
...(cfg.host !== undefined ? { host: cfg.host } : {}),
|
|
378
|
+
// Self-wake targets THIS process's OWN tmux pane (inherited $TMUX_PANE).
|
|
379
|
+
resolveLaunchContext: () => detectTmuxLaunchContext(process.env, undefined, `h2a mcp-serve --host ${cfg.host ?? ""}`.trim()),
|
|
380
|
+
...(wakeCfg.nativeSessionId !== undefined
|
|
381
|
+
? { resolveNativeSessionId: () => wakeCfg.nativeSessionId }
|
|
382
|
+
: {}),
|
|
383
|
+
log: (line) => stderr.write(`h2a mcp-serve: ${line}\n`)
|
|
384
|
+
});
|
|
385
|
+
server.notifications.setOnInboxArrival((instance) => {
|
|
386
|
+
if (instance === wakeInstance)
|
|
387
|
+
void wake();
|
|
388
|
+
});
|
|
389
|
+
stderr.write(`h2a mcp-serve: inbox-wake armed for ${wakeInstance}\n`);
|
|
390
|
+
}
|
|
391
|
+
// Eager auto-open: identity was resolved synchronously before the transport
|
|
392
|
+
// (the historical path, kept for existing consumers/tests). Best-effort unless
|
|
393
|
+
// a structured readiness challenge upgrades failure to fatal.
|
|
394
|
+
if (options.autoOpen) {
|
|
395
|
+
try {
|
|
396
|
+
autoOpenedSessionId = openAutoOpenSession(options.autoOpen);
|
|
397
|
+
}
|
|
234
398
|
catch (err) {
|
|
235
399
|
const message = err instanceof Error ? err.message : String(err);
|
|
236
400
|
stderr.write(`h2a mcp-serve: auto-open failed: ${message}\n`);
|
|
@@ -239,32 +403,99 @@ export function runMcpStdio(options) {
|
|
|
239
403
|
throw new Error(`structured auto-open failed: ${message}`);
|
|
240
404
|
}
|
|
241
405
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
406
|
+
if (options.wake)
|
|
407
|
+
armInboxWake(options.autoOpen, options.wake);
|
|
408
|
+
}
|
|
409
|
+
// L2 deferred identity: the transport is already live (initialize / tools/list
|
|
410
|
+
// / h2a_identity_status answer immediately). The child worker resolves identity
|
|
411
|
+
// OFF this event loop; only once it is really bound do we open the presence
|
|
412
|
+
// session, arm the live signer and wake, and publish the correlated readiness
|
|
413
|
+
// ACK. No provisional key, no early availability ACK.
|
|
414
|
+
if (options.identityRequest) {
|
|
415
|
+
const activation = options.identityActivation;
|
|
416
|
+
const activate = (identity) => {
|
|
417
|
+
if (!activation) {
|
|
418
|
+
return { ok: false, cause: "identity_worker_failed", message: "no activation wiring" };
|
|
419
|
+
}
|
|
420
|
+
// An explicit --instance override resolves with no key path: presence
|
|
421
|
+
// opens but no signer is available (h2a_send stays refused, honestly).
|
|
422
|
+
let privateKeyPem;
|
|
423
|
+
if (identity.privateKeyPath) {
|
|
424
|
+
try {
|
|
425
|
+
privateKeyPem = readFileSync(identity.privateKeyPath, "utf8");
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
cause: "identity_storage_failed",
|
|
431
|
+
message: `cannot read identity key: ${err instanceof Error ? err.message : String(err)}`
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const cfg = activation.buildAutoOpen(identity);
|
|
436
|
+
let sessionId;
|
|
437
|
+
try {
|
|
438
|
+
sessionId = openAutoOpenSession(cfg);
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
return {
|
|
442
|
+
ok: false,
|
|
443
|
+
cause: "session_open_failed",
|
|
444
|
+
message: err instanceof Error ? err.message : String(err)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
autoOpenedSessionId = sessionId;
|
|
448
|
+
// F1: if any later activation step fails, roll ALL partial work back so a
|
|
449
|
+
// failed identity never leaves a live presence session, a live signer, an
|
|
450
|
+
// armed self-wake, or (worse) a published readiness ACK behind it.
|
|
451
|
+
const rollback = () => {
|
|
452
|
+
try {
|
|
453
|
+
server.sessions.close(sessionId, "closed");
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
/* best effort */
|
|
457
|
+
}
|
|
458
|
+
// Disarm the inbox-wake (it signs self-wake lines) — a no-op handler.
|
|
459
|
+
try {
|
|
460
|
+
server.notifications.setOnInboxArrival(() => { });
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
/* best effort */
|
|
464
|
+
}
|
|
465
|
+
liveSendContext = undefined;
|
|
466
|
+
autoOpenedSessionId = undefined;
|
|
467
|
+
};
|
|
468
|
+
if (privateKeyPem !== undefined) {
|
|
469
|
+
const wakeCfg = activation.buildWake?.(privateKeyPem, cfg.instance, cfg.host);
|
|
470
|
+
if (wakeCfg)
|
|
471
|
+
armInboxWake(cfg, wakeCfg);
|
|
472
|
+
// Only NOW is a trusted local signer available for h2a_send.
|
|
473
|
+
liveSendContext = { instance: cfg.instance, privateKeyPem };
|
|
474
|
+
}
|
|
475
|
+
if (options.readiness) {
|
|
476
|
+
try {
|
|
477
|
+
publishReadinessAck(options.readiness, sessionId);
|
|
478
|
+
trace?.phase("readiness_ack");
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
// The ACK is the LAST step; a failure here would otherwise leave the
|
|
482
|
+
// session/signer/wake live under a `failed` state — roll them back.
|
|
483
|
+
rollback();
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
cause: "readiness_ack_failed",
|
|
487
|
+
message: err instanceof Error ? err.message : String(err)
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, sessionId, signer: liveSendContext };
|
|
492
|
+
};
|
|
493
|
+
identityController = createIdentityController({
|
|
494
|
+
request: options.identityRequest,
|
|
495
|
+
activate,
|
|
496
|
+
log: (line) => stderr.write(`h2a mcp-serve: ${line}\n`)
|
|
497
|
+
});
|
|
498
|
+
identityController.start();
|
|
268
499
|
}
|
|
269
500
|
// Publish only after every synchronous boot step above has completed and
|
|
270
501
|
// immediately before constructing the stdio loop. Auto-upgrade/re-exec runs
|
|
@@ -272,6 +503,7 @@ export function runMcpStdio(options) {
|
|
|
272
503
|
if (options.readiness && autoOpenedSessionId) {
|
|
273
504
|
try {
|
|
274
505
|
publishReadinessAck(options.readiness, autoOpenedSessionId);
|
|
506
|
+
trace?.phase("readiness_ack");
|
|
275
507
|
}
|
|
276
508
|
catch (err) {
|
|
277
509
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -288,14 +520,15 @@ export function runMcpStdio(options) {
|
|
|
288
520
|
// sessions so presence is marked `closed` immediately rather than lingering
|
|
289
521
|
// as false-live until expiry. Idempotent with the rl `close` path below.
|
|
290
522
|
const onAbort = () => {
|
|
291
|
-
shutdown();
|
|
523
|
+
shutdown("signal");
|
|
292
524
|
try {
|
|
293
525
|
rl.close();
|
|
294
526
|
}
|
|
295
527
|
catch {
|
|
296
528
|
// ignore
|
|
297
529
|
}
|
|
298
|
-
|
|
530
|
+
// Flush the bounded writer before resolving so no queued frame is lost.
|
|
531
|
+
void writeChain.finally(() => resolve());
|
|
299
532
|
};
|
|
300
533
|
if (options.signal) {
|
|
301
534
|
if (options.signal.aborted) {
|
|
@@ -318,7 +551,7 @@ export function runMcpStdio(options) {
|
|
|
318
551
|
}
|
|
319
552
|
catch (err) {
|
|
320
553
|
const message = err instanceof Error ? err.message : String(err);
|
|
321
|
-
|
|
554
|
+
emitResponse(null, errorResponse(null, -32700, "Parse error", message));
|
|
322
555
|
return;
|
|
323
556
|
}
|
|
324
557
|
// JSON-RPC 2.0: a message with NO `id` member is a NOTIFICATION — the
|
|
@@ -330,16 +563,61 @@ export function runMcpStdio(options) {
|
|
|
330
563
|
// at startup. Gate every stdout write on the message actually being a
|
|
331
564
|
// request (DEC-115).
|
|
332
565
|
const isNotification = !("id" in request);
|
|
566
|
+
// L1: reject an incoming id we cannot correlate within budget (a huge
|
|
567
|
+
// string, an object, an array) BEFORE building any response — so every
|
|
568
|
+
// response id below is a finite number, null, or a ≤128-byte string.
|
|
569
|
+
if (!isNotification && !isAcceptableRequestId(request.id)) {
|
|
570
|
+
emitResponse(null, buildInvalidRequestId());
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
333
573
|
if (request.jsonrpc !== "2.0" || typeof request.method !== "string") {
|
|
334
574
|
if (!isNotification) {
|
|
335
|
-
|
|
575
|
+
emitResponse((request.id ?? null), errorResponse(request.id ?? null, -32600, "Invalid Request"));
|
|
336
576
|
}
|
|
337
577
|
return;
|
|
338
578
|
}
|
|
339
579
|
try {
|
|
580
|
+
const requestId = typeof request.id === "string" || typeof request.id === "number"
|
|
581
|
+
? request.id
|
|
582
|
+
: undefined;
|
|
583
|
+
if (trace && request.method === "initialize") {
|
|
584
|
+
trace.phase("initialize_recv", {
|
|
585
|
+
method: "initialize",
|
|
586
|
+
...(requestId !== undefined ? { requestId } : {})
|
|
587
|
+
});
|
|
588
|
+
}
|
|
340
589
|
const result = handleMethod(server, request.method, request.params);
|
|
341
590
|
if (!isNotification) {
|
|
342
|
-
|
|
591
|
+
// L1: the SINGLE bounded writer serializes ONCE, bounds the exact UTF-8
|
|
592
|
+
// bytes to the frame budget (an oversize result becomes a bounded -32010
|
|
593
|
+
// with a recovery ref — never a raw >B write), and returns the frame
|
|
594
|
+
// ACTUALLY emitted so the L0 calibration measures the true wire size.
|
|
595
|
+
const emitted = emitResponse((request.id ?? null), successResponse(request.id ?? null, result));
|
|
596
|
+
// L0 calibration semantics: the PAYLOAD bytes (no trailing newline),
|
|
597
|
+
// as before the single-writer refactor. The budget itself counts the
|
|
598
|
+
// newline; the trace stays payload-exact so the L0 witness is unchanged.
|
|
599
|
+
const bytes = Buffer.byteLength(emitted.json, "utf8");
|
|
600
|
+
if (trace) {
|
|
601
|
+
const rid = requestId !== undefined ? { requestId } : {};
|
|
602
|
+
if (request.method === "initialize") {
|
|
603
|
+
trace.phase("initialize_sent", { bytes, ...rid });
|
|
604
|
+
}
|
|
605
|
+
else if (request.method === "tools/list") {
|
|
606
|
+
trace.phase("tools_list_sent", { bytes, ...rid });
|
|
607
|
+
}
|
|
608
|
+
else if (request.method === "tools/call") {
|
|
609
|
+
const rawName = request.params && typeof request.params === "object"
|
|
610
|
+
? request.params.name
|
|
611
|
+
: undefined;
|
|
612
|
+
const toolField = typeof rawName === "string" ? { tool: rawName } : {};
|
|
613
|
+
trace.phase(firstToolCallTraced ? "tool_sent" : "tool_first_sent", {
|
|
614
|
+
bytes,
|
|
615
|
+
...rid,
|
|
616
|
+
...toolField
|
|
617
|
+
});
|
|
618
|
+
firstToolCallTraced = true;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
343
621
|
}
|
|
344
622
|
}
|
|
345
623
|
catch (err) {
|
|
@@ -353,17 +631,19 @@ export function runMcpStdio(options) {
|
|
|
353
631
|
return;
|
|
354
632
|
}
|
|
355
633
|
if (err instanceof MethodNotFoundError) {
|
|
356
|
-
|
|
634
|
+
emitResponse((request.id ?? null), errorResponse(request.id ?? null, -32601, `Method not found: ${err.method}`));
|
|
357
635
|
return;
|
|
358
636
|
}
|
|
359
637
|
const message = err instanceof Error ? err.message : String(err);
|
|
360
638
|
stderr.write(`h2a mcp-serve: internal error: ${message}\n`);
|
|
361
|
-
|
|
639
|
+
emitResponse((request.id ?? null), errorResponse(request.id ?? null, -32603, `Internal error: ${message}`));
|
|
362
640
|
}
|
|
363
641
|
});
|
|
364
642
|
rl.on("close", () => {
|
|
365
643
|
shutdown();
|
|
366
|
-
|
|
644
|
+
// Flush the bounded writer before resolving so the last frame is on the
|
|
645
|
+
// wire before the process (bin.ts awaits this promise) can exit.
|
|
646
|
+
void writeChain.finally(() => resolve());
|
|
367
647
|
});
|
|
368
648
|
rl.on("error", (err) => {
|
|
369
649
|
shutdown();
|