@henols/vice-mcp 0.1.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/README.md +66 -0
- package/build.ts +268 -0
- package/container-guard.mts +226 -0
- package/containerpath.ts +296 -0
- package/hostpath.ts +318 -0
- package/incident-record.ts +442 -0
- package/install-resources.ts +532 -0
- package/package.json +71 -0
- package/refresh-manifest.ts +103 -0
- package/repo-root.ts +198 -0
- package/resources/broker-control.mjs +343 -0
- package/resources/broker-epoch.mjs +126 -0
- package/resources/broker-kill.mjs +491 -0
- package/resources/broker-launch.mjs +659 -0
- package/resources/broker-state.mjs +173 -0
- package/resources/container-guard.mjs +211 -0
- package/resources/vice-broker.mjs +855 -0
- package/resources/vice-launcher.sh +169 -0
- package/tools-manifest.json +1231 -0
- package/vice-broker-client.ts +899 -0
- package/vice-probe.ts +278 -0
- package/vice-proxy.ts +3093 -0
- package/vice-sync.ts +336 -0
- package/vice.ts +772 -0
package/vice-probe.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The deliberately-FRAGILE liveness probe -- the counterpart to
|
|
3
|
+
// tools/vice.mjs's resilient withReconnect() ladder, and the two must NEVER
|
|
4
|
+
// be merged. Where the seam retries a transport failure over a ~50s backoff
|
|
5
|
+
// budget because a call has to eventually succeed, this module answers one
|
|
6
|
+
// question fast: is anything actually listening and answering right now.
|
|
7
|
+
//
|
|
8
|
+
// WHAT WENT WRONG BEFORE (this module exists because of it): the host
|
|
9
|
+
// supervisor died, its epoch froze, and the FIRST thing anyone noticed was a
|
|
10
|
+
// call burning ~50s of reconnect backoff before failing -- acquire() had
|
|
11
|
+
// handed out a port based only on the registry CLAIMING an instance existed,
|
|
12
|
+
// never on anything actually answering there (.planning/STATE.md's HOST
|
|
13
|
+
// INSTABILITY / HARD BLOCKER entries, 2026-07-30). Probing four dead
|
|
14
|
+
// candidates through that ladder would take minutes just to report an empty
|
|
15
|
+
// pool -- unacceptable for a health check that has to run on every acquire()
|
|
16
|
+
// poll pass.
|
|
17
|
+
//
|
|
18
|
+
// WHY vice_ping AND ONLY vice_ping: it is the one call measured NON-pausing
|
|
19
|
+
// -- 986,693 cycles/s while ping-polling versus 991,569 fully quiet
|
|
20
|
+
// (.planning/STATE.md's pause-on-read finding, 2026-07-30). Every other
|
|
21
|
+
// state-reading vice_* call PAUSES the emulator and does not resume it,
|
|
22
|
+
// which would make probing itself destructive to the very instance being
|
|
23
|
+
// probed. Check that measurement against STATE.md directly rather than
|
|
24
|
+
// trusting this comment if it ever needs re-verifying.
|
|
25
|
+
//
|
|
26
|
+
// This module takes NO static dependency on tools/vice.mjs (the transport
|
|
27
|
+
// seam) -- importing it is exactly how the resilient retry path would leak
|
|
28
|
+
// into a probe. probeAll() below fans a single-shot request sequence out
|
|
29
|
+
// across several candidate ports at once, none of which the seam is (or
|
|
30
|
+
// should be) pointed at; the seam's module-level "active instance" state has
|
|
31
|
+
// no business being touched by a health check.
|
|
32
|
+
//
|
|
33
|
+
// The one exception (quick-260730-q4b, D-3): a side-effect-only import of
|
|
34
|
+
// repo-root.ts, below. This module is the ONE skill file that imports
|
|
35
|
+
// nothing else from the skill, so without this line the deploy-on-first-use
|
|
36
|
+
// resource check never fires when this probe is the entry point. This does
|
|
37
|
+
// NOT violate the no-dependencies stance above, which is specifically about
|
|
38
|
+
// never importing vice.mjs's resilient retry ladder into this deliberately-
|
|
39
|
+
// fragile probe -- repo-root.ts is a pure path resolver plus this one
|
|
40
|
+
// trigger, and pulls in nothing that speaks MCP.
|
|
41
|
+
import "./repo-root.ts";
|
|
42
|
+
|
|
43
|
+
/** The one and only tool this module will ever call. Hardcoded, not a
|
|
44
|
+
* parameter on any exported function -- that is the structural reason no
|
|
45
|
+
* probe caller can ever steer this module at a forbidden tool (T-p5x-03,
|
|
46
|
+
* D-7): there is no plumbing through which a tool name could arrive. */
|
|
47
|
+
export const PROBE_TOOL = "vice_ping";
|
|
48
|
+
|
|
49
|
+
/** ~1-2s, configurable (D-3). A probe is meant to answer fast; a caller that
|
|
50
|
+
* wants a more patient check (or a stricter one) can override per call. */
|
|
51
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = Number(process.env.VICE_PROBE_TIMEOUT_MS || 1500);
|
|
52
|
+
|
|
53
|
+
/** Options accepted by probeInstance(): a target `url`/`port` pair and an
|
|
54
|
+
* optional per-call timeout override (defaults to DEFAULT_PROBE_TIMEOUT_MS). */
|
|
55
|
+
export interface ProbeInstanceOptions {
|
|
56
|
+
url: string;
|
|
57
|
+
port: number;
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The verdict probeInstance() always resolves to -- never throws, per the
|
|
62
|
+
* function's own doc comment below. `reason` is null exactly when
|
|
63
|
+
* `alive` is true; `ping` carries the decoded vice_ping payload when one was
|
|
64
|
+
* recognisable, otherwise null (or the raw text when it didn't parse as
|
|
65
|
+
* JSON, matching the pre-existing behavior of the function this was ported
|
|
66
|
+
* from). */
|
|
67
|
+
export type ProbeResult = {
|
|
68
|
+
port: number;
|
|
69
|
+
url: string;
|
|
70
|
+
alive: boolean;
|
|
71
|
+
ms: number;
|
|
72
|
+
reason: string | null;
|
|
73
|
+
ping: unknown;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** A single probe target, as probeAll() consumes it -- the caller's own
|
|
77
|
+
* responsibility to have derived `url` from a validated integer `port`
|
|
78
|
+
* (T-p5x-01), never from a string read straight out of a registry file. */
|
|
79
|
+
export interface ProbeInstanceRef {
|
|
80
|
+
port: number;
|
|
81
|
+
url: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Options accepted by probeAll(): the same per-call timeout override
|
|
85
|
+
* probeInstance() takes, applied uniformly to every candidate in the fan-out. */
|
|
86
|
+
export interface ProbeAllOptions {
|
|
87
|
+
timeoutMs?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The combined result of probeAll(): the plain per-instance results array,
|
|
91
|
+
* plus a `byPort` Map for O(1) lookup by port without re-scanning. */
|
|
92
|
+
export interface ProbeAllResult {
|
|
93
|
+
results: ProbeResult[];
|
|
94
|
+
byPort: Map<number, ProbeResult>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse an MCP HTTP response body exactly the two ways tools/vice.mjs's
|
|
99
|
+
* rpc() already does: an SSE-framed body's last `data:` line, or a plain
|
|
100
|
+
* JSON body. Deliberately duplicated here, not imported from the seam --
|
|
101
|
+
* see this file's header comment for why sharing that code would mean
|
|
102
|
+
* sharing the seam's module state too. Throws on anything unparseable; the
|
|
103
|
+
* caller turns that into an `alive:false` verdict, never lets it escape.
|
|
104
|
+
*/
|
|
105
|
+
function parseMcpBody(text: string, contentType: string): unknown {
|
|
106
|
+
if (contentType.includes("text/event-stream")) {
|
|
107
|
+
const dataLines = text
|
|
108
|
+
.split("\n")
|
|
109
|
+
.filter((l) => l.startsWith("data:"))
|
|
110
|
+
.map((l) => l.slice(5).trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
if (!dataLines.length) {
|
|
113
|
+
throw new Error("no data: lines in SSE response");
|
|
114
|
+
}
|
|
115
|
+
return JSON.parse(dataLines[dataLines.length - 1]);
|
|
116
|
+
}
|
|
117
|
+
return JSON.parse(text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
121
|
+
* an array. Used to narrow parseMcpBody()'s `unknown` result before reading
|
|
122
|
+
* its `.error`/`.result` fields, in the same style as vice-broker.mts's
|
|
123
|
+
* isPlainObject(). */
|
|
124
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
125
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* One single-shot MCP round trip sequence against `url`: the `initialize`
|
|
130
|
+
* handshake followed by a `tools/call` of PROBE_TOOL. A SINGLE
|
|
131
|
+
* `AbortSignal.timeout(timeoutMs)` is created once, before either request,
|
|
132
|
+
* and shared by both -- total wall time is bounded by `timeoutMs` no matter
|
|
133
|
+
* how many round trips the handshake costs. No retry, no backoff, no second
|
|
134
|
+
* attempt at anything: this is the deliberately-fragile counterpart to
|
|
135
|
+
* withReconnect(), and reusing that ladder here would silently reintroduce
|
|
136
|
+
* the ~50s-per-dead-candidate problem this module exists to avoid (D-3).
|
|
137
|
+
*
|
|
138
|
+
* NEVER THROWS. Every failure mode -- a rejected fetch, an abort, a non-2xx
|
|
139
|
+
* status, a JSON-RPC `error` member, or a 200 that doesn't decode to a
|
|
140
|
+
* recognisable ping result -- becomes `{ alive: false, reason }` instead, so
|
|
141
|
+
* a caller can always build a per-candidate rejection report without a
|
|
142
|
+
* try/catch of its own.
|
|
143
|
+
*
|
|
144
|
+
* Returns `{ port, url, alive, ms, reason, ping }`.
|
|
145
|
+
*/
|
|
146
|
+
export async function probeInstance({
|
|
147
|
+
url,
|
|
148
|
+
port,
|
|
149
|
+
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
150
|
+
}: ProbeInstanceOptions): Promise<ProbeResult> {
|
|
151
|
+
const startedAt = Date.now();
|
|
152
|
+
const elapsed = () => Date.now() - startedAt;
|
|
153
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
154
|
+
|
|
155
|
+
const post = async (body: unknown) =>
|
|
156
|
+
fetch(url, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
Accept: "application/json, text/event-stream",
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify(body),
|
|
163
|
+
signal,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const initRes = await post({
|
|
168
|
+
jsonrpc: "2.0",
|
|
169
|
+
id: 1,
|
|
170
|
+
method: "initialize",
|
|
171
|
+
params: {
|
|
172
|
+
protocolVersion: "2024-11-05",
|
|
173
|
+
capabilities: {},
|
|
174
|
+
clientInfo: { name: "bruce-lee-probe", version: "1.0" },
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
if (!initRes.ok) {
|
|
178
|
+
return { port, url, alive: false, ms: elapsed(), reason: `initialize returned HTTP ${initRes.status}`, ping: null };
|
|
179
|
+
}
|
|
180
|
+
let initPayload: unknown;
|
|
181
|
+
try {
|
|
182
|
+
initPayload = parseMcpBody(await initRes.text(), initRes.headers.get("content-type") || "");
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { port, url, alive: false, ms: elapsed(), reason: `initialize: unparseable response (${(e as Error).message})`, ping: null };
|
|
185
|
+
}
|
|
186
|
+
if (isPlainObject(initPayload) && initPayload.error) {
|
|
187
|
+
const initError = initPayload.error;
|
|
188
|
+
const initErrorMessage = isPlainObject(initError) && typeof initError.message === "string" ? initError.message : "unknown";
|
|
189
|
+
return {
|
|
190
|
+
port, url, alive: false, ms: elapsed(),
|
|
191
|
+
reason: `initialize RPC error: ${initErrorMessage}`,
|
|
192
|
+
ping: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const callRes = await post({
|
|
197
|
+
jsonrpc: "2.0",
|
|
198
|
+
id: 2,
|
|
199
|
+
method: "tools/call",
|
|
200
|
+
params: { name: PROBE_TOOL, arguments: {} },
|
|
201
|
+
});
|
|
202
|
+
if (!callRes.ok) {
|
|
203
|
+
return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL} returned HTTP ${callRes.status}`, ping: null };
|
|
204
|
+
}
|
|
205
|
+
let callPayload: unknown;
|
|
206
|
+
try {
|
|
207
|
+
callPayload = parseMcpBody(await callRes.text(), callRes.headers.get("content-type") || "");
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL}: unparseable response (${(e as Error).message})`, ping: null };
|
|
210
|
+
}
|
|
211
|
+
if (isPlainObject(callPayload) && callPayload.error) {
|
|
212
|
+
const callError = callPayload.error;
|
|
213
|
+
const callErrorMessage = isPlainObject(callError) && typeof callError.message === "string" ? callError.message : "unknown";
|
|
214
|
+
return {
|
|
215
|
+
port, url, alive: false, ms: elapsed(),
|
|
216
|
+
reason: `${PROBE_TOOL} RPC error: ${callErrorMessage}`,
|
|
217
|
+
ping: null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const result = isPlainObject(callPayload) ? callPayload.result : undefined;
|
|
222
|
+
const contentArray = isPlainObject(result) && Array.isArray(result.content) ? result.content : undefined;
|
|
223
|
+
const content = contentArray ? contentArray[0] : undefined;
|
|
224
|
+
if (!isPlainObject(content) || content.type !== "text") {
|
|
225
|
+
return { port, url, alive: false, ms: elapsed(), reason: `${PROBE_TOOL}: unexpected tool result shape`, ping: null };
|
|
226
|
+
}
|
|
227
|
+
let ping: unknown;
|
|
228
|
+
try {
|
|
229
|
+
ping = JSON.parse(content.text as string);
|
|
230
|
+
} catch {
|
|
231
|
+
ping = content.text;
|
|
232
|
+
}
|
|
233
|
+
// A 200 that doesn't decode to a recognisable ping result (no "version"
|
|
234
|
+
// field) is something ELSE listening on that port -- not the same as
|
|
235
|
+
// VICE being up (T-p5x-04).
|
|
236
|
+
if (!isPlainObject(ping) || typeof ping.version === "undefined") {
|
|
237
|
+
return {
|
|
238
|
+
port, url, alive: false, ms: elapsed(),
|
|
239
|
+
reason: `something answered on this port but did not return a recognisable ping result (no "version" field) -- not VICE`,
|
|
240
|
+
ping: ping ?? null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { port, url, alive: true, ms: elapsed(), reason: null, ping };
|
|
245
|
+
} catch (e) {
|
|
246
|
+
const ms = elapsed();
|
|
247
|
+
const err = e as Error & { name?: string; cause?: unknown };
|
|
248
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
249
|
+
return { port, url, alive: false, ms, reason: `no response within ${timeoutMs}ms (timeout)`, ping: null };
|
|
250
|
+
}
|
|
251
|
+
// Surface the underlying cause code (ECONNREFUSED etc.) when fetch's
|
|
252
|
+
// undici implementation attaches one, rather than a generic message --
|
|
253
|
+
// "nothing is listening" and "something is wrong with the request" are
|
|
254
|
+
// different diagnoses and the reason string should say which.
|
|
255
|
+
const cause = err.cause;
|
|
256
|
+
const causeCode = isPlainObject(cause) && typeof cause.code === "string" ? cause.code : undefined;
|
|
257
|
+
return { port, url, alive: false, ms, reason: causeCode || err.message, ping: null };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Probe every instance in `instances` concurrently -- N candidates cost one
|
|
263
|
+
* timeout, not N (D-3). Takes instance OBJECTS (`{ port, url }`), never raw
|
|
264
|
+
* registry entries: the caller is responsible for having derived `url` from
|
|
265
|
+
* a validated integer port (T-p5x-01), never from a string read out of a
|
|
266
|
+
* registry file. Returns both the plain results array and a `byPort` Map so
|
|
267
|
+
* callers can look a verdict up without re-scanning.
|
|
268
|
+
*/
|
|
269
|
+
export async function probeAll(
|
|
270
|
+
instances: ProbeInstanceRef[],
|
|
271
|
+
{ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS }: ProbeAllOptions = {}
|
|
272
|
+
): Promise<ProbeAllResult> {
|
|
273
|
+
const results = await Promise.all(
|
|
274
|
+
instances.map((inst) => probeInstance({ url: inst.url, port: inst.port, timeoutMs }))
|
|
275
|
+
);
|
|
276
|
+
const byPort = new Map(results.map((r) => [r.port, r]));
|
|
277
|
+
return { results, byPort };
|
|
278
|
+
}
|