@henols/vice-mcp 0.2.0 → 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.
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ // r2000-verify.ts -- the ONE place that interprets regenerator2000's
3
+ // `--verify` output.
4
+ //
5
+ // WHY PARSING IS REQUIRED AT ALL (D-10, the concrete incident): with ACME
6
+ // absent from PATH and ca65 present, a real `regenerator2000 0.9.20 --verify`
7
+ // run on this host printed
8
+ //
9
+ // ✗ ACME — ACME not found in PATH (skipped)
10
+ // ✓ All roundtrip verifications passed.
11
+ // EXIT=0
12
+ //
13
+ // -- exit 0, and a summary line that reads as a full pass, while the one
14
+ // assembler this project actually cares about (`!cpu 6510`, ACME 0.97) never
15
+ // ran at all. Trusting the exit code here would let ACME be silently
16
+ // skipped and still report success. This is the WHAT-NOT-TO-DO for any
17
+ // future edit to this file: never derive `ok` from `status`, ever, no
18
+ // matter how tempting a bare zero-exit-status check looks. The verdict this
19
+ // module produces comes ONLY from parsing the per-assembler result lines
20
+ // and reading ACME's own line -- never from the process exit code, and
21
+ // never from the aggregate "All roundtrip verifications passed." summary
22
+ // line, which is itself the thing that lied in the transcript above.
23
+ //
24
+ // Both captured transcripts (the honest pass and this exact false-pass trap)
25
+ // are pinned verbatim as fixtures in r2000-verify.test.ts, so a future
26
+ // "simplification" back to an exit-code check fails a unit test immediately.
27
+ //
28
+ // WR-04 (10-REVIEW.md, fixed in plan 11-01): the verdict must also never
29
+ // trust just the FIRST ACME result line. `acmeVerdict()` used to select
30
+ // ACME's line with a bare array .find() over the first matching entry, so
31
+ // a transcript containing both a
32
+ // passing and a failing ACME line (a shape --verify has never printed as of
33
+ // 0.9.20, but one this defensive parser is explicitly meant to survive)
34
+ // reported `ok: true` from the first (passing) line while discarding the
35
+ // later failure -- exactly the "misleading success" this module exists to
36
+ // refuse. The fix requires UNANIMITY: every parsed ACME line must be `ok`,
37
+ // the first non-ok line (if any) drives the verdict, and if more than one
38
+ // ACME line is present after passing that check, the module refuses to
39
+ // guess which one is authoritative rather than picking one arbitrarily.
40
+ // Both the mixed-transcript case and the too-many-ACME-lines case are pinned
41
+ // verbatim as fixtures in r2000-verify.test.ts.
42
+ //
43
+ // Import nothing from `hostpath.ts`/`containerpath.ts` -- plan 10-01's
44
+ // absence assertion in `hostpath-consumers.test.ts` already names this file.
45
+
46
+ import { buildVerifyArgs, runR2000 } from "./r2000-launch.ts";
47
+
48
+ /** The three possible outcomes for a single per-assembler `--verify` result
49
+ * line. `"skipped"` and `"ok"` are DIFFERENT outcomes and must never be
50
+ * conflated -- a skipped assembler did not run, so it proves nothing, while
51
+ * an `ok` assembler was actually invoked and its output byte-diffed. */
52
+ export type AssemblerOutcome = "ok" | "skipped" | "failed";
53
+
54
+ export interface VerifyLine {
55
+ assembler: string;
56
+ outcome: AssemblerOutcome;
57
+ detail: string;
58
+ }
59
+
60
+ // Matches exactly a per-assembler result line, e.g.:
61
+ // ✓ ACME — byte-identical (44 bytes)
62
+ // ✗ 64tass — 64tass not found in PATH (skipped)
63
+ // Tolerates an em-dash (U+2014), en-dash (U+2013) or plain hyphen as the
64
+ // separator, since only the ACME verdict is load-bearing here and the exact
65
+ // glyph regenerator2000 prints is an upstream formatting detail, not
66
+ // something this parser should be brittle against. Deliberately does NOT
67
+ // match the aggregate "✓ All roundtrip verifications passed." summary line
68
+ // (no separator token present there) or the "EXIT=N" line some transcripts
69
+ // carry -- both are excluded from the returned array by construction, not
70
+ // by a special-cased skip: they simply never match this shape.
71
+ const VERIFY_LINE_PATTERN = /^[✓✗]\s+(.+?)\s+[—–-]\s+(.+)$/;
72
+
73
+ /**
74
+ * Parses `regenerator2000 --verify`'s stdout into one `VerifyLine` per
75
+ * per-assembler result line. The aggregate `✓ All roundtrip verifications
76
+ * passed.` line is a summary, not an assembler line -- it never matches
77
+ * `VERIFY_LINE_PATTERN` (no `—`/`-` separator), so it is excluded from the
78
+ * result by construction rather than filtered out after the fact.
79
+ */
80
+ export function parseVerifyOutput(stdout: string): VerifyLine[] {
81
+ const lines: VerifyLine[] = [];
82
+ for (const rawLine of stdout.split(/\r?\n/)) {
83
+ const trimmed = rawLine.trim();
84
+ if (!trimmed) continue;
85
+ const match = trimmed.match(VERIFY_LINE_PATTERN);
86
+ if (!match) continue;
87
+ const [, assemblerRaw, detailRaw] = match;
88
+ const assembler = assemblerRaw!.trim();
89
+ const detail = detailRaw!.trim();
90
+ // A "(skipped)" suffix always means skipped, regardless of the leading
91
+ // glyph. Anything else takes its outcome from the leading glyph: a
92
+ // leading ✓ is "ok", any other non-skipped result line is "failed" --
93
+ // this project has never observed a real ACME/ca65 failure transcript,
94
+ // but a future one must not silently parse as "ok".
95
+ const outcome: AssemblerOutcome = /\(skipped\)\s*$/i.test(detail)
96
+ ? "skipped"
97
+ : trimmed.startsWith("✓")
98
+ ? "ok"
99
+ : "failed";
100
+ lines.push({ assembler, outcome, detail });
101
+ }
102
+ return lines;
103
+ }
104
+
105
+ /**
106
+ * Derives the ACME-specific verdict from a parsed line set. `ok` only when
107
+ * at least one ACME line exists, EVERY parsed ACME line has outcome `"ok"`
108
+ * (unanimity -- WR-04), AND exactly one ACME line is present. A missing
109
+ * ACME line, any ACME line with outcome `"skipped"` or `"failed"`, and more
110
+ * than one `"ok"` ACME line each return `ok: false` with a distinct,
111
+ * quotable reason -- never conflate "skipped" with "passed", never let a
112
+ * passing line hide a later failing one, and never fall back to the summary
113
+ * line (which this module never even parses as a VerifyLine, see
114
+ * `parseVerifyOutput`).
115
+ */
116
+ export function acmeVerdict(lines: VerifyLine[]): { ok: boolean; reason: string } {
117
+ const acmeLines = lines.filter((l) => l.assembler.toLowerCase() === "acme");
118
+
119
+ if (acmeLines.length === 0) {
120
+ return {
121
+ ok: false,
122
+ reason:
123
+ "no ACME line found in --verify output -- ACME was never invoked at all, which is a failure, " +
124
+ "not an absence of evidence",
125
+ };
126
+ }
127
+
128
+ // First non-ok line wins -- so a passing ACME line earlier in the
129
+ // transcript can never hide a failing one later in it (WR-04).
130
+ const bad = acmeLines.find((l) => l.outcome !== "ok");
131
+
132
+ if (bad?.outcome === "skipped") {
133
+ return {
134
+ ok: false,
135
+ reason:
136
+ `ACME was skipped, not run -- "${bad.detail}". A skipped ACME is a failure, never a pass ` +
137
+ `(D-10): --verify can print "✓ All roundtrip verifications passed." and exit 0 even when ` +
138
+ `ACME never ran at all -- exactly the false pass observed live on this host with ACME absent ` +
139
+ `and ca65 present.`,
140
+ };
141
+ }
142
+
143
+ if (bad?.outcome === "failed") {
144
+ return { ok: false, reason: `ACME reported a failure: "${bad.detail}"` };
145
+ }
146
+
147
+ // Every ACME line is ok at this point. Still refuse to guess which one is
148
+ // authoritative if more than one was printed (WR-04) -- unanimous is not
149
+ // the same as unambiguous.
150
+ if (acmeLines.length > 1) {
151
+ return {
152
+ ok: false,
153
+ reason: `--verify printed ${acmeLines.length} ACME result lines -- refusing to guess which one is the verdict`,
154
+ };
155
+ }
156
+
157
+ return { ok: true, reason: `ACME reported: ${acmeLines[0]!.detail}` };
158
+ }
159
+
160
+ export interface VerifyProjectResult {
161
+ ok: boolean;
162
+ reason: string;
163
+ lines: VerifyLine[];
164
+ status: number | null;
165
+ stdout: string;
166
+ stderr: string;
167
+ }
168
+
169
+ /**
170
+ * Runs `regenerator2000 --verify` against `projectPath` (via
171
+ * `buildVerifyArgs()`/`runR2000()`, so the `--vice` scan applies here too),
172
+ * parses stdout, and derives `ok` from `acmeVerdict()` -- deliberately NOT
173
+ * from `status`. Returns the raw status and streams so a caller can print
174
+ * them for diagnostics, but no code path in this function ever lets a zero
175
+ * exit status alone make `ok` true. A warning on stderr is never treated as
176
+ * a failure -- only the parsed ACME result line decides the verdict.
177
+ */
178
+ export function verifyProject(projectPath: string): VerifyProjectResult {
179
+ const argv = buildVerifyArgs({ projectPath });
180
+ const { status, stdout, stderr } = runR2000(argv);
181
+ const lines = parseVerifyOutput(stdout);
182
+ const verdict = acmeVerdict(lines);
183
+ return { ok: verdict.ok, reason: verdict.reason, lines, status, stdout, stderr };
184
+ }
package/stock-symbols.ts CHANGED
@@ -26,11 +26,17 @@
26
26
  // The confirmed input format is a VICE label file, one `al C:xxxx .Name`
27
27
  // line per symbol, verified against ACME's `--vicelabels` output via
28
28
  // acme-build/scripts/acme.mjs's own parser (curateLabels(),
29
- // `/^al\s+C:[0-9a-f]+\s+\.(\S+)/i`). STATED ASSUMPTION, NOT A VERIFIED FACT:
30
- // regenerator2000's `--export_lbl` is *expected* to emit the same syntax,
31
- // but R2000-16(c) has never been run -- hence the parser below SKIPS
32
- // unrecognised lines rather than refusing the whole file, and no comment or
33
- // doc here may claim "regenerator2000-compatible" as verified.
29
+ // `/^al\s+C:[0-9a-f]+\s+\.(\S+)/i`). VERIFIED (Phase 9, R2000-16(c)):
30
+ // regenerator2000 0.9.20's `--export_lbl` was run against the
31
+ // probe-illegal.prg-derived fixture and emitted `al C:0810 .init_screen`,
32
+ // which matches this module's own VICE_LABEL_LINE_RE
33
+ // (`/^al\s+C:([0-9a-fA-F]{1,4})\s+\.(\S+)/`) exactly. This claim is SCOPED to
34
+ // regenerator2000 0.9.20 and that fixture -- not to all inputs forever (the
35
+ // same scoping caveat ROADMAP.md applies to Phase 9's criterion 3(3) `pass`).
36
+ // The parser below still SKIPS unrecognised lines rather than refusing the
37
+ // whole file: a future regenerator2000 version, a hand-edited label file, or
38
+ // a different exporter entirely can still produce lines this format should
39
+ // tolerate rather than reject outright.
34
40
  //
35
41
  // WHAT NOT TO DO:
36
42
  // - Never add a second resolver holder or call setSymbolResolver() from
@@ -75,8 +81,12 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
75
81
  const VICE_LABEL_LINE_RE = /^al\s+C:([0-9a-fA-F]{1,4})\s+\.(\S+)/;
76
82
 
77
83
  /** T-05-02-03: three independent resource ceilings, each refusing with both
78
- * the observed value and the limit named. */
79
- const MAX_LABEL_FILE_BYTES = 2 * 1024 * 1024;
84
+ * the observed value and the limit named. `MAX_LABEL_FILE_BYTES` is exported
85
+ * (11-08, Rule A20) so `r2000-symbols.ts`'s `exportLabels()`/`importLabels()`
86
+ * can apply the SAME byte ceiling to a regenerator2000-produced/-consumed
87
+ * `.lbl` file before ever calling `parseViceLabelFile()` below -- never a
88
+ * second hand-copied number. */
89
+ export const MAX_LABEL_FILE_BYTES = 2 * 1024 * 1024;
80
90
  const MAX_LABEL_FILE_LINES = 50000;
81
91
  const MAX_SYMBOLS = 20000;
82
92
 
@@ -98,7 +108,11 @@ export class StockSymbolsError extends ViceError {
98
108
  }
99
109
  }
100
110
 
101
- interface SymbolTable {
111
+ /** Exported alongside `parseViceLabelFile()` (11-08, Rule A20) purely so a
112
+ * cross-module caller can name this shape in its own type annotations --
113
+ * this module's own internal state (`loadedTable` below) still never leaves
114
+ * this file. */
115
+ export interface SymbolTable {
102
116
  byName: Map<string, number>;
103
117
  byAddress: Map<number, string>;
104
118
  }
@@ -186,7 +200,17 @@ function resolveLabelFilePath(pathArg: unknown): string {
186
200
  // counted, never a whole-file refusal.
187
201
  // ---------------------------------------------------------------------------
188
202
 
189
- function parseViceLabelFile(text: string): {
203
+ /**
204
+ * Exported (11-08, Rule A20) so `r2000-symbols.ts` can validate a
205
+ * regenerator2000-produced `.lbl` file (or check a caller-supplied one
206
+ * BEFORE it is ever handed to a spawned regenerator2000 child) through THIS
207
+ * parser -- the ONE `al C:xxxx .Name` reader in this repo -- rather than
208
+ * adding a second copy of `VICE_LABEL_LINE_RE`. Ceiling violations
209
+ * (`MAX_LABEL_FILE_LINES`/`MAX_SYMBOLS`) throw `StockSymbolsError` exactly as
210
+ * they do for `handleSymbolsLoad` below; a caller across the module boundary
211
+ * is expected to surface that error verbatim, never re-wrap it.
212
+ */
213
+ export function parseViceLabelFile(text: string): {
190
214
  table: SymbolTable;
191
215
  symbolCount: number;
192
216
  skippedLines: number;
package/vice-proxy.ts CHANGED
@@ -185,6 +185,126 @@ import * as stockDispatch from "./stock-dispatch.ts";
185
185
  // below, strictly after the DENY_LIST check -- see the comment at that call
186
186
  // site for why the ordering is load-bearing.
187
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
+ }
188
308
 
189
309
  const HERE_DIR = dirname(fileURLToPath(import.meta.url));
190
310
 
@@ -3220,6 +3340,32 @@ tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) =>
3220
3340
  // entry in `tools/list`.
3221
3341
  tools[RECYCLE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(RECYCLE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleRecycle(args));
3222
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
+ }
3223
3369
 
3224
3370
  const server = new MCPServer({ name: "vice", version: PROXY_VERSION, tools });
3225
3371
  await server.startStdio();