@henols/vice-mcp 0.1.12 → 0.2.1

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/version.ts ADDED
@@ -0,0 +1,279 @@
1
+ // The ONE authoritative implementation of this project's version-resolution
2
+ // algorithm (D-5). Before this file existed the repo carried FOUR
3
+ // hand-maintained version strings and none of them were true:
4
+ // `.claude/mcp/vice/package.json` and `installer/package.json` were stale
5
+ // placeholders CI never touched between releases, `.claude-plugin/plugin.json`
6
+ // was bumped by NO automation at all, and `vice-proxy.ts`'s own
7
+ // `PROXY_VERSION` literal -- advertised to every MCP client over
8
+ // `initialize` -- sat twelve patches behind npm's actual `latest`. This file
9
+ // exists so there is exactly one place that (a) parses the repo-root
10
+ // `VERSION` template, (b) resolves it against a published version per D-2's
11
+ // four rules, and (c) answers "what version am I, right now, at runtime"
12
+ // (D-4) -- every other consumer (the CLI in `scripts/version.mjs`,
13
+ // `vice-proxy.ts`'s `PROXY_VERSION`, CI's `release-on-merge` job) calls INTO
14
+ // this module rather than re-deriving any part of the algorithm locally.
15
+ //
16
+ // Do NOT reimplement the resolution rules (`pinned` / `no-published` /
17
+ // `prefix-differs` / `prefix-matches`) anywhere else in this repo -- not in
18
+ // `scripts/version.mjs`, not in CI YAML, not in `installer/bin/cli.mjs`.
19
+ // `version.test.ts`'s "single-implementation guard" test greps
20
+ // `scripts/version.mjs` for the rule literals to catch exactly that
21
+ // regression.
22
+ //
23
+ // Do NOT import `repo-root.ts` from this file. That module's `repoRoot()`
24
+ // carries a documented side effect (`ensureResourcesInstalled()`, fired at
25
+ // module-load time) this seam must never trigger just by being imported --
26
+ // `scripts/version.mjs` and any future test that imports this module for
27
+ // its pure functions alone would otherwise deploy host launcher resources as
28
+ // a side effect of asking what version something is. Per this repo's own
29
+ // convention (see `hostpath.ts`/`containerpath.ts`), the workspace root
30
+ // arrives here as an explicit argument (`readTemplate(repoRootDir)`) or a
31
+ // caller-supplied lazy thunk (`runtimeVersion({ repoRoot })`), never as an
32
+ // import this module resolves itself.
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+
36
+ /** The single self-evident placeholder every derived, publishable version
37
+ * string (the two npm package.json `.version` fields, the installer's
38
+ * `@henols/vice-mcp` dependency pin, and the three plugin-manifest version
39
+ * fields) carries in the working tree (R-2). Valid semver -- `npm pack`
40
+ * accepts it -- but unmistakably not a release. Overwritten at publish time
41
+ * by `npm version` (npm packages) or `scripts/version.mjs stamp` (plugin
42
+ * manifests); never hand-edited. */
43
+ export const DEV_PLACEHOLDER = "0.0.0-dev";
44
+
45
+ export type ResolveRule = "pinned" | "no-published" | "prefix-differs" | "prefix-matches";
46
+
47
+ export interface ResolveResult {
48
+ version: string;
49
+ rule: ResolveRule;
50
+ template: string;
51
+ published: string | null;
52
+ }
53
+
54
+ // Leading zeros are rejected (bare "0" is the only zero-value exception) --
55
+ // SemVer 2.0.0 SS2 forbids leading-zero numeric identifiers, and this
56
+ // component is echoed VERBATIM into the resolved string for literal (non-`-`)
57
+ // slots, so "1.00.0" must be rejected here rather than surfacing later as an
58
+ // npm-publish-time failure (MED-2).
59
+ const TEMPLATE_COMPONENT = /^(0|[1-9]\d*|-)$/;
60
+
61
+ /**
62
+ * Parse a version TEMPLATE (not a resolved version) into its three
63
+ * dot-separated components. Each component must be either a non-negative
64
+ * integer literal or the literal string `-` (an auto-managed slot, D-2).
65
+ * Throws on anything else: wrong component count, a non-numeric/non-`-`
66
+ * component, a blank/whitespace-only string, OR a literal component
67
+ * appearing after a `-` component (MED-1) -- D-2's "literal prefix" wording,
68
+ * and every row of CONTEXT.md's worked-example table, only ever describe
69
+ * dashes trailing literals, never leading or interleaved. A shape like
70
+ * "-.2.3" or "0.-.2" has no defined resolution semantics and must be
71
+ * rejected at the one seam that owns validating the hand-edited VERSION
72
+ * file, not silently resolved into something CONTEXT.md never specified.
73
+ */
74
+ export function parseTemplate(raw: string): string[] {
75
+ const trimmed = raw.trim();
76
+ if (trimmed === "") {
77
+ throw new Error(`version.ts: malformed template ${JSON.stringify(raw)} -- empty`);
78
+ }
79
+ const parts = trimmed.split(".");
80
+ if (parts.length !== 3) {
81
+ throw new Error(
82
+ `version.ts: malformed template ${JSON.stringify(raw)} -- expected exactly 3 dot-separated components, got ${parts.length}`
83
+ );
84
+ }
85
+ let sawDash = false;
86
+ for (const part of parts) {
87
+ if (part === "-") {
88
+ sawDash = true;
89
+ continue;
90
+ }
91
+ if (sawDash) {
92
+ throw new Error(
93
+ `version.ts: malformed template ${JSON.stringify(raw)} -- literal component ${JSON.stringify(part)} cannot follow a "-" component; dashes only trail literals (D-2)`
94
+ );
95
+ }
96
+ if (!TEMPLATE_COMPONENT.test(part)) {
97
+ throw new Error(
98
+ `version.ts: malformed template ${JSON.stringify(raw)} -- component ${JSON.stringify(part)} is neither an integer (no leading zeros) nor "-"`
99
+ );
100
+ }
101
+ }
102
+ return parts;
103
+ }
104
+
105
+ /**
106
+ * Parse a PUBLISHED version string into a 3-tuple of numbers, stripping any
107
+ * `-prerelease` / `+build` suffix first. Returns null (never throws) when
108
+ * the input is null, not parseable, or does not have exactly 3 numeric
109
+ * dot-separated components -- callers treat null exactly like "nothing is
110
+ * published" (D-2 rule 4).
111
+ */
112
+ function parsePublished(published: string | null): number[] | null {
113
+ if (published == null) return null;
114
+ const core = published.split(/[-+]/, 1)[0];
115
+ const parts = core.split(".");
116
+ if (parts.length !== 3) return null;
117
+ // Strict plain-decimal-digit validation (MED-4) -- deliberately NOT
118
+ // `Number(p)` + `Number.isInteger`, which also accepts hex-like literals
119
+ // ("0x2" -> 2), exponential notation ("5e2" -> 500), and whitespace-padded
120
+ // numbers. Those would silently coerce malformed `--published` input or an
121
+ // unexpected `npm view` response into a number that doesn't reflect the
122
+ // original text; this must instead fall back to null (== "no-published",
123
+ // D-2 rule 4) exactly like any other unparseable input, matching the
124
+ // strict digit validation `parseTemplate`'s `TEMPLATE_COMPONENT` already
125
+ // uses for the same kind of input.
126
+ if (!parts.every((p) => /^\d+$/.test(p))) return null;
127
+ return parts.map((p) => Number(p));
128
+ }
129
+
130
+ /**
131
+ * The D-2 resolution algorithm, in full. `template` must already be
132
+ * well-formed (call `parseTemplate` first, or pass through unchanged --
133
+ * this function calls it internally so a malformed template always throws
134
+ * here too). `published` is the raw published-version string (or null);
135
+ * this function does its own stripping/parsing via `parsePublished`.
136
+ */
137
+ export function resolveVersion(template: string, published: string | null): ResolveResult {
138
+ const components = parseTemplate(template);
139
+
140
+ if (!components.includes("-")) {
141
+ return { version: components.join("."), rule: "pinned", template, published };
142
+ }
143
+
144
+ const pub = parsePublished(published);
145
+
146
+ if (pub === null) {
147
+ const resolved = components.map((c) => (c === "-" ? "0" : c));
148
+ return { version: resolved.join("."), rule: "no-published", template, published };
149
+ }
150
+
151
+ const prefixMatches = components.every((c, i) => c === "-" || Number(c) === pub[i]);
152
+
153
+ if (!prefixMatches) {
154
+ const resolved = components.map((c) => (c === "-" ? "0" : c));
155
+ return { version: resolved.join("."), rule: "prefix-differs", template, published };
156
+ }
157
+
158
+ let firstDashSeen = false;
159
+ const resolved = components.map((c, i) => {
160
+ if (c !== "-") return c;
161
+ if (!firstDashSeen) {
162
+ firstDashSeen = true;
163
+ // Guard (LOW-1): `pub[i] + 1` on a published component at
164
+ // Number.MAX_SAFE_INTEGER would silently lose precision to float
165
+ // rounding and could fail to actually increment, violating this
166
+ // seam's "always publishes something new" invariant. Purely
167
+ // theoretical for real-world semver (patch counts in the billions are
168
+ // not realistic) but the arithmetic is load-bearing enough to fail
169
+ // loud rather than silently miscompute.
170
+ if (pub[i] >= Number.MAX_SAFE_INTEGER) {
171
+ throw new Error(
172
+ `version.ts: published component ${pub[i]} at index ${i} is at or beyond Number.MAX_SAFE_INTEGER -- refusing to increment (would lose precision)`
173
+ );
174
+ }
175
+ return String(pub[i] + 1);
176
+ }
177
+ return "0";
178
+ });
179
+ return { version: resolved.join("."), rule: "prefix-matches", template, published };
180
+ }
181
+
182
+ /**
183
+ * Compare two plain, fully-resolved (no `-`, no prerelease) semver-shaped
184
+ * version strings as a numeric 3-tuple. Returns -1/0/1. Throws if either
185
+ * side does not parse as 3 numeric components -- this is for comparing
186
+ * RESOLVED versions, not templates.
187
+ */
188
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
189
+ const pa = parsePublished(a);
190
+ const pb = parsePublished(b);
191
+ if (pa === null) throw new Error(`version.ts: compareVersions() cannot parse ${JSON.stringify(a)}`);
192
+ if (pb === null) throw new Error(`version.ts: compareVersions() cannot parse ${JSON.stringify(b)}`);
193
+ for (let i = 0; i < 3; i++) {
194
+ if (pa[i] < pb[i]) return -1;
195
+ if (pa[i] > pb[i]) return 1;
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ /**
201
+ * Read `<repoRootDir>/VERSION`, trimmed. Returns the empty string for an
202
+ * existing-but-blank (or whitespace-only) file, and null ONLY when the file
203
+ * is absent (never throws on either outcome) -- do not treat `=== null` as
204
+ * the sole "no template" signal; an empty string is also "no usable
205
+ * template", and every current caller checks for it via truthiness (LOW-3).
206
+ * Does NOT validate the template shape -- `resolveVersion`/`parseTemplate`
207
+ * do that; this function's only job is the filesystem read.
208
+ */
209
+ export function readTemplate(repoRootDir: string): string | null {
210
+ const path = join(repoRootDir, "VERSION");
211
+ if (!existsSync(path)) return null;
212
+ try {
213
+ return readFileSync(path, "utf8").trim();
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ export interface RuntimeVersionOptions {
220
+ pkgJsonPath?: string;
221
+ /** LAZY -- see this file's header and the precedence note below. Only
222
+ * called when `pkgJsonPath`'s own version is absent or is
223
+ * DEV_PLACEHOLDER, so a published tarball (which has a real version and
224
+ * no repo-root VERSION file) never calls this and never risks the
225
+ * stderr note `repoRoot()` implementations may emit. */
226
+ repoRoot?: () => string | undefined;
227
+ }
228
+
229
+ /**
230
+ * D-4's runtime precedence, synchronous and never throwing:
231
+ *
232
+ * 1. `pkgJsonPath`'s own `.version`, when present and not DEV_PLACEHOLDER
233
+ * -- the published-tarball path: `npm version` already stamped it.
234
+ * 2. Otherwise call `opts.repoRoot?.()` and, if it yields a directory,
235
+ * read that directory's `VERSION` template. A pinned template (no `-`)
236
+ * returns verbatim; a template containing `-` resolves every `-` to 0
237
+ * and appends a `-dev` prerelease tag, so a dev checkout never claims
238
+ * to be a release build.
239
+ * 3. Otherwise DEV_PLACEHOLDER.
240
+ *
241
+ * The whole body is wrapped in try/catch: any unexpected failure (a
242
+ * malformed package.json, a repoRoot() thunk that throws, a malformed
243
+ * VERSION template) degrades to DEV_PLACEHOLDER rather than crashing the
244
+ * caller -- this is the one runtime-facing entry point in this file, and a
245
+ * standalone MCP server must never fail to start over a version string.
246
+ */
247
+ export function runtimeVersion(opts: RuntimeVersionOptions = {}): string {
248
+ try {
249
+ if (opts.pkgJsonPath) {
250
+ try {
251
+ const raw = readFileSync(opts.pkgJsonPath, "utf8");
252
+ const pkg = JSON.parse(raw) as { version?: unknown };
253
+ if (typeof pkg.version === "string" && pkg.version.length > 0 && pkg.version !== DEV_PLACEHOLDER) {
254
+ return pkg.version;
255
+ }
256
+ } catch {
257
+ // No readable/parseable package.json at pkgJsonPath -- fall through.
258
+ }
259
+ }
260
+
261
+ const root = opts.repoRoot?.();
262
+ if (root) {
263
+ const template = readTemplate(root);
264
+ if (template) {
265
+ const components = parseTemplate(template);
266
+ if (!components.includes("-")) {
267
+ return components.join(".");
268
+ }
269
+ const resolved = components.map((c) => (c === "-" ? "0" : c));
270
+ return `${resolved.join(".")}-dev`;
271
+ }
272
+ }
273
+ } catch {
274
+ // Degrade to the placeholder below -- see this function's own doc
275
+ // comment for why nothing here may throw.
276
+ }
277
+
278
+ return DEV_PLACEHOLDER;
279
+ }
package/vice-proxy.ts CHANGED
@@ -100,6 +100,10 @@ import {
100
100
  // reconnect ladder.
101
101
  import { probeInstance, type ProbeResult } from "./vice-probe.ts";
102
102
  import { repoRoot } from "./repo-root.ts";
103
+ // The single version-resolution seam (quick-260819-tsz, D-5) -- PROXY_VERSION
104
+ // below is the only consumer in this file; see version.ts's own header for
105
+ // why this file must never re-derive any part of the algorithm itself.
106
+ import { runtimeVersion } from "./version.ts";
103
107
  import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
104
108
  // The INVERSE direction (host -> container), for inverting a broker grant's
105
109
  // own host-local coordinates before useInstance() ever adopts them (this
@@ -176,6 +180,131 @@ import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
176
180
  // and every call site.
177
181
  import * as backendDetect from "./backend-detect.mts";
178
182
  import * as stockDispatch from "./stock-dispatch.ts";
183
+ // Plan 08-02: the single per-backend capability lookup (BACK-05), consumed
184
+ // only inside the CallToolRequestSchema override's tools[name] miss branch
185
+ // below, strictly after the DENY_LIST check -- see the comment at that call
186
+ // site for why the ordering is load-bearing.
187
+ import { capabilityRefusalMessage } from "./capability-registry.ts";
188
+ // Plan 11-05: the curated r2000_* tool surface's DEFINITIONS, imported
189
+ // STATICALLY -- registration below happens synchronously at module scope, so
190
+ // a dynamic import cannot serve it. This costs no child process and no
191
+ // socket: the heavy part (the MCP client, the spawned regenerator2000 child)
192
+ // stays behind r2000-tools.ts's own `await import("./r2000-mcp-client.ts")`
193
+ // inside runR2000Tool() itself, reached only when a tool is actually called.
194
+ import { R2000_TOOL_DEFINITIONS, runR2000Tool } from "./r2000-tools.ts";
195
+
196
+ // ------------------------------------------------------------ r2000 subcommand
197
+ //
198
+ // D-06 / RESEARCH.md Open Question #1 (plan 10-04): `vice-mcp r2000 <verb>` is
199
+ // the ONLY surface that resolves identically across the Claude Code plugin
200
+ // route and both npm-installer routes -- `installer/bin/cli.mjs`'s
201
+ // `viceServerEntry()` always launches this server via `npx` in BOTH
202
+ // npm-installer modes (`--vendor` only pre-resolves the package; it never
203
+ // places `.claude/mcp/vice/*.ts` as plain files inside a consuming project),
204
+ // so any design resolving a filesystem path to the seam would silently fail
205
+ // to resolve for npm-installed users. This bin is the one surface proven to
206
+ // work in all three routes.
207
+ //
208
+ // This branch runs as the first executable statement of the module body,
209
+ // deliberately ABOVE `ACTIVE_BACKEND`'s backend probe (which shells out to a
210
+ // binary's `--help`), above the manifest read, and above
211
+ // `new MCPServer(...)`/`server.startStdio()` far below -- a CLI invocation
212
+ // must never open a socket, never probe a binary, and never write a byte of
213
+ // JSON-RPC to stdout. WHAT NOT TO DO: never let this branch fall through
214
+ // into the server path, and never print anything on stdout on the server
215
+ // path that a CLI caller could confuse for `r2000` output.
216
+ //
217
+ // Ending the process here is deliberate and is NOT a violation of this
218
+ // file's standing "never end the process from a teardown handler" rule (see
219
+ // that handler's own comment further down): that rule protects the
220
+ // long-lived server's lease-release path, and this branch ends the process
221
+ // before any lease, socket or handler exists. A dynamic import is used
222
+ // (not a static one) so the CLI module is not part of the server's startup
223
+ // cost on the normal, non-`r2000` path.
224
+ //
225
+ // IN-01 (10-REVIEW.md; 11.1-CONTEXT.md AUDIT-01, D-11.1-04): `console.log`/
226
+ // `console.error` writes to `process.stdout`/`process.stderr` are
227
+ // ASYNCHRONOUS on POSIX once the fd is a pipe (Node opens pipe/socket fds
228
+ // non-blocking, unlike a TTY or a regular file), so a bare `process.exit()`
229
+ // immediately after can discard whatever write has not yet drained --
230
+ // measured at a 128 KiB truncation point on this host's Node for a single
231
+ // write exceeding the OS pipe's capacity. The reachable trigger is
232
+ // `cmdExportAsm`'s `console.error(result.stderr)` in r2000-cli.ts, which can
233
+ // carry a large diagnostic from the spawned regenerator2000 child: the one
234
+ // case where the user most needs the diagnostic is exactly the case a piped
235
+ // invocation could silently lose it in. `drainStdio()` below explicitly
236
+ // awaits both streams' own pending writes (a `write("", cb)`-style
237
+ // zero-length write's callback fires only once every prior queued write has
238
+ // actually flushed) before the terminating `process.exit(code)`.
239
+ //
240
+ // T-11.1-EXITHANG: the drain is BOUNDED to `R2000_CLI_DRAIN_TIMEOUT_MS`. An
241
+ // exit that hangs forever waiting on a pipe nobody reads is worse than a
242
+ // truncated diagnostic -- this project's standing rule is that a teardown
243
+ // path never becomes a hang (see the "never end the process from a teardown
244
+ // handler" comment above; a BOUNDED drain here does not violate that rule
245
+ // for the same reason the original unbounded `process.exit()` did not: this
246
+ // still runs before any lease, socket or handler exists, and now also can
247
+ // never block indefinitely).
248
+ const R2000_CLI_DRAIN_TIMEOUT_MS = 300;
249
+
250
+ /** Resolves once `stream`'s own pending writes have flushed, or after
251
+ * `timeoutMs`, whichever comes first. A zero-length `write("", cb)`'s
252
+ * callback fires strictly after every write queued ahead of it on the same
253
+ * stream has completed -- so this is a genuine drain barrier, not a fixed
254
+ * sleep. Guarded so a stream that is not writable (already closed/ended,
255
+ * e.g. under `> /dev/null` teardown races) resolves immediately rather than
256
+ * calling `write()` on it. */
257
+ function drainStdio(stream: NodeJS.WriteStream): Promise<void> {
258
+ return new Promise((resolve) => {
259
+ if (!stream.writable) {
260
+ resolve();
261
+ return;
262
+ }
263
+ const timer = setTimeout(resolve, R2000_CLI_DRAIN_TIMEOUT_MS);
264
+ stream.write("", () => {
265
+ clearTimeout(timer);
266
+ resolve();
267
+ });
268
+ });
269
+ }
270
+
271
+ if (process.argv[2] === "r2000") {
272
+ // A broken pipe (the reader closing early, e.g. `| head`) makes
273
+ // `stream.write()` fail with EPIPE. Awaiting `drainStdio()` below gives
274
+ // that failure the chance to actually surface as Node's stream 'error'
275
+ // event -- which throws UNCAUGHT and crashes the process if nothing is
276
+ // listening, converting what used to be a silent (bare `process.exit()`
277
+ // outran the async error) success into a stack-trace crash. This
278
+ // mirrors the SAME EPIPE class this file already guards against for the
279
+ // server path further down (see that handler's own
280
+ // modelcontextprotocol/typescript-sdk#1564 citation) -- registered here
281
+ // too because this branch exits long before reaching that one.
282
+ process.stdout.on("error", () => {});
283
+ process.stderr.on("error", () => {});
284
+
285
+ // Test-only escape hatch, never documented to end users and inert unless
286
+ // this exact env var is set: writes a deterministic filler payload
287
+ // through this SAME drained-exit path, so vice-proxy.test.ts can measure
288
+ // an exact byte count well above any OS pipe capacity without needing a
289
+ // real regenerator2000 project (empirically, neither `--help`'s ~5.6 KB
290
+ // USAGE text nor a synthesized/garbage `.regen2000proj` fed to
291
+ // export-asm's error path scales anywhere near 128 KiB on this host's
292
+ // regenerator2000 0.9.20 -- both were measured before this hatch was
293
+ // added; see 11.1-05-SUMMARY.md for the measurements). Never reachable
294
+ // from a real `r2000 <verb>` invocation: the check is against a specific,
295
+ // unambiguous env var name no real caller would ever set.
296
+ const testFillBytes = process.env.VICE_TEST_R2000_CLI_STDOUT_FILL_BYTES;
297
+ if (testFillBytes) {
298
+ process.stdout.write("x".repeat(Number(testFillBytes)));
299
+ await Promise.all([drainStdio(process.stdout), drainStdio(process.stderr)]);
300
+ process.exit(0);
301
+ }
302
+
303
+ const { runR2000Cli } = await import("./r2000-cli.ts");
304
+ const code = await runR2000Cli(process.argv.slice(3));
305
+ await Promise.all([drainStdio(process.stdout), drainStdio(process.stderr)]);
306
+ process.exit(code);
307
+ }
179
308
 
180
309
  const HERE_DIR = dirname(fileURLToPath(import.meta.url));
181
310
 
@@ -254,8 +383,19 @@ process.stdout.on("error", (err) => {
254
383
  // override that preserves this file's own `{content, isError}` wire
255
384
  // contract exactly (see this plan's "Ground truth" section for why
256
385
  // MCPServer's OWN tools/call dispatch cannot be used as-is). PROXY_VERSION
257
- // survives unchanged, reused as MCPServer's own `version` field.
258
- const PROXY_VERSION = "0.1.0";
386
+ // no longer survives as a hand-edited literal (quick-260819-tsz, D-4/D-5):
387
+ // it used to say "0.1.0" while npm's actual `latest` was twelve patches
388
+ // ahead, because nothing updated it. It is now derived through
389
+ // `runtimeVersion()` (the ONE seam, `./version.ts`), which reads this
390
+ // package's own `package.json` first (the published-tarball path, where
391
+ // `npm version` already stamped a real number) and falls back to the
392
+ // repo-root `VERSION` template -- rendered as `<resolved>-dev` -- only in a
393
+ // git checkout, degrading to `0.0.0-dev` if neither is available. Reused,
394
+ // unchanged, as MCPServer's own `version` field below.
395
+ const PROXY_VERSION = runtimeVersion({
396
+ pkgJsonPath: join(HERE_DIR, "package.json"),
397
+ repoRoot: () => repoRoot(),
398
+ });
259
399
 
260
400
  // --------------------------------------------------------------- tools/list
261
401
  //
@@ -3176,7 +3316,11 @@ function buildBackendAwareTool(def: ToolDefinition, forkRun: (args: Record<strin
3176
3316
  }
3177
3317
 
3178
3318
  const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
3179
- for (const def of readManifestTools()) {
3319
+ // Read ONCE and reused below for both the manifest loop and the two
3320
+ // synthetic registrations' own resolveAdvertisedToolDefinition() calls --
3321
+ // never re-read per registration (WR-07, plan 07-16).
3322
+ const manifestTools = readManifestTools();
3323
+ for (const def of manifestTools) {
3180
3324
  if (DENY_LIST.includes(def.name)) continue;
3181
3325
  tools[def.name] = buildBackendAwareTool(def, (args) => forwardToVice(def.name, args));
3182
3326
  }
@@ -3187,9 +3331,41 @@ for (const def of readManifestTools()) {
3187
3331
  tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
3188
3332
  // Backend-AWARE (CR-07): both of these gather evidence over the fork's HTTP
3189
3333
  // transport, so on stock they are refused by name rather than advertised and
3190
- // then failed at the wire.
3191
- tools[RECYCLE_TOOL.name] = buildBackendAwareTool(RECYCLE_TOOL, (args) => handleRecycle(args));
3192
- tools[DIAGNOSE_TOOL.name] = buildBackendAwareTool(DIAGNOSE_TOOL, (args) => handleDiagnose(args));
3334
+ // then failed at the wire. WR-07 (plan 07-16): the ADVERTISED definition is
3335
+ // now also backend-aware -- resolveAdvertisedToolDefinition() picks the
3336
+ // corrected stock manifest entry on stock (falling back to the synthetic
3337
+ // definition when the manifest has none) and always returns the synthetic
3338
+ // definition unchanged on the fork, so RECYCLE_TOOL/DIAGNOSE_TOOL's literal
3339
+ // fork-worded text below no longer overwrites the stock manifest's own
3340
+ // entry in `tools/list`.
3341
+ tools[RECYCLE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(RECYCLE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleRecycle(args));
3342
+ tools[DIAGNOSE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(DIAGNOSE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleDiagnose(args));
3343
+ // Backend-INDEPENDENT by construction (plan 11-05): the r2000_* family never
3344
+ // touches VICE at all -- regenerator2000 is a separate, static-analysis child
3345
+ // process, so there is no fork/stock distinction to make and
3346
+ // buildBackendAwareTool() would be flatly wrong here (there is nothing for it
3347
+ // to dispatch to on either backend). The family is in NEITHER
3348
+ // tools-manifest.json NOR tools-manifest.stock.json: both are regenerated by
3349
+ // refresh-manifest.ts from a live HOST VICE server's own tools/list, and an
3350
+ // r2000 child process is never that host -- a hand-added entry in either
3351
+ // manifest would be silently wiped on the next refresh. Registered here via
3352
+ // buildViceTool() directly (the SAME exception RESULT_CONTINUE_TOOL above
3353
+ // is), so no r2000_* runner can ever reach forwardToVice(), call(), or
3354
+ // ensureViceSession() -- CLAUDE.md's "derived tools must be intercepted
3355
+ // before forwardToVice()" constraint is satisfied by construction for this
3356
+ // family, not by an interception, because the runner is never wired to
3357
+ // forwardToVice() in the first place.
3358
+ // Deliberately NOT named `def` (the manifest loop's own loop variable,
3359
+ // above): `stock-dispatch.test.ts`'s `proxyToolRegistrations()` regex-scans
3360
+ // this file's own `tools[...] = ...;` lines and keys each one by its raw
3361
+ // captured text, so an identically-named loop variable here would make this
3362
+ // registration textually indistinguishable from the manifest loop's -- a
3363
+ // distinct name (`r2000Def`) keeps the r2000 family's own exemption from
3364
+ // `buildBackendAwareTool()` from ever being confused with, or accidentally
3365
+ // widened to cover, the manifest loop's registration.
3366
+ for (const r2000Def of R2000_TOOL_DEFINITIONS) {
3367
+ tools[r2000Def.name] = buildViceTool(r2000Def, (args) => runR2000Tool(r2000Def.name, args));
3368
+ }
3193
3369
 
3194
3370
  const server = new MCPServer({ name: "vice", version: PROXY_VERSION, tools });
3195
3371
  await server.startStdio();
@@ -3243,6 +3419,19 @@ server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
3243
3419
  // .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md.
3244
3420
  const tool = tools[name];
3245
3421
  if (!tool || !tool.execute) {
3422
+ // Layer 3 (BACK-05, plan 08-02): fires ONLY when the ACTIVE backend's
3423
+ // trimmed manifest (D-07) never registered this name -- i.e. `tools`
3424
+ // has no key for it -- and MUST stay strictly after the DENY_LIST check
3425
+ // above: those four meta-tool names are a confused-deputy bypass hazard
3426
+ // (01.4-01), not a capability gap, and must never be reachable here.
3427
+ // This lookup renders undefined for a genuinely unknown name (or a
3428
+ // same-backend miss), so a real typo still falls through to the generic
3429
+ // message below unchanged. capability-registry.ts is the ONE place to
3430
+ // edit this data -- never hand-add a per-tool special case here.
3431
+ const capabilityRefusal = capabilityRefusalMessage(name, ACTIVE_BACKEND.backend);
3432
+ if (capabilityRefusal !== undefined) {
3433
+ return { content: [{ type: "text", text: capabilityRefusal }], isError: true };
3434
+ }
3246
3435
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
3247
3436
  }
3248
3437
  try {