@henols/vice-mcp 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,11 @@ 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";
179
188
 
180
189
  const HERE_DIR = dirname(fileURLToPath(import.meta.url));
181
190
 
@@ -254,8 +263,19 @@ process.stdout.on("error", (err) => {
254
263
  // override that preserves this file's own `{content, isError}` wire
255
264
  // contract exactly (see this plan's "Ground truth" section for why
256
265
  // 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";
266
+ // no longer survives as a hand-edited literal (quick-260819-tsz, D-4/D-5):
267
+ // it used to say "0.1.0" while npm's actual `latest` was twelve patches
268
+ // ahead, because nothing updated it. It is now derived through
269
+ // `runtimeVersion()` (the ONE seam, `./version.ts`), which reads this
270
+ // package's own `package.json` first (the published-tarball path, where
271
+ // `npm version` already stamped a real number) and falls back to the
272
+ // repo-root `VERSION` template -- rendered as `<resolved>-dev` -- only in a
273
+ // git checkout, degrading to `0.0.0-dev` if neither is available. Reused,
274
+ // unchanged, as MCPServer's own `version` field below.
275
+ const PROXY_VERSION = runtimeVersion({
276
+ pkgJsonPath: join(HERE_DIR, "package.json"),
277
+ repoRoot: () => repoRoot(),
278
+ });
259
279
 
260
280
  // --------------------------------------------------------------- tools/list
261
281
  //
@@ -3176,7 +3196,11 @@ function buildBackendAwareTool(def: ToolDefinition, forkRun: (args: Record<strin
3176
3196
  }
3177
3197
 
3178
3198
  const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
3179
- for (const def of readManifestTools()) {
3199
+ // Read ONCE and reused below for both the manifest loop and the two
3200
+ // synthetic registrations' own resolveAdvertisedToolDefinition() calls --
3201
+ // never re-read per registration (WR-07, plan 07-16).
3202
+ const manifestTools = readManifestTools();
3203
+ for (const def of manifestTools) {
3180
3204
  if (DENY_LIST.includes(def.name)) continue;
3181
3205
  tools[def.name] = buildBackendAwareTool(def, (args) => forwardToVice(def.name, args));
3182
3206
  }
@@ -3187,9 +3211,15 @@ for (const def of readManifestTools()) {
3187
3211
  tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
3188
3212
  // Backend-AWARE (CR-07): both of these gather evidence over the fork's HTTP
3189
3213
  // 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));
3214
+ // then failed at the wire. WR-07 (plan 07-16): the ADVERTISED definition is
3215
+ // now also backend-aware -- resolveAdvertisedToolDefinition() picks the
3216
+ // corrected stock manifest entry on stock (falling back to the synthetic
3217
+ // definition when the manifest has none) and always returns the synthetic
3218
+ // definition unchanged on the fork, so RECYCLE_TOOL/DIAGNOSE_TOOL's literal
3219
+ // fork-worded text below no longer overwrites the stock manifest's own
3220
+ // entry in `tools/list`.
3221
+ tools[RECYCLE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(RECYCLE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleRecycle(args));
3222
+ tools[DIAGNOSE_TOOL.name] = buildBackendAwareTool(stockDispatch.resolveAdvertisedToolDefinition(DIAGNOSE_TOOL, ACTIVE_BACKEND.backend, manifestTools), (args) => handleDiagnose(args));
3193
3223
 
3194
3224
  const server = new MCPServer({ name: "vice", version: PROXY_VERSION, tools });
3195
3225
  await server.startStdio();
@@ -3243,6 +3273,19 @@ server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
3243
3273
  // .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md.
3244
3274
  const tool = tools[name];
3245
3275
  if (!tool || !tool.execute) {
3276
+ // Layer 3 (BACK-05, plan 08-02): fires ONLY when the ACTIVE backend's
3277
+ // trimmed manifest (D-07) never registered this name -- i.e. `tools`
3278
+ // has no key for it -- and MUST stay strictly after the DENY_LIST check
3279
+ // above: those four meta-tool names are a confused-deputy bypass hazard
3280
+ // (01.4-01), not a capability gap, and must never be reachable here.
3281
+ // This lookup renders undefined for a genuinely unknown name (or a
3282
+ // same-backend miss), so a real typo still falls through to the generic
3283
+ // message below unchanged. capability-registry.ts is the ONE place to
3284
+ // edit this data -- never hand-add a per-tool special case here.
3285
+ const capabilityRefusal = capabilityRefusalMessage(name, ACTIVE_BACKEND.backend);
3286
+ if (capabilityRefusal !== undefined) {
3287
+ return { content: [{ type: "text", text: capabilityRefusal }], isError: true };
3288
+ }
3246
3289
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
3247
3290
  }
3248
3291
  try {