@adhisang/minecraft-modding-mcp 7.0.0-rc.0 → 7.0.0-rc.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/CHANGELOG.md CHANGED
@@ -7,65 +7,93 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [7.0.0-rc.1] - 2026-08-28
11
+
12
+ ### Added
13
+
14
+ - A class-not-found error on a Jar-in-Jar shell now includes a `nestedJars` array listing the shell's inner jars by entry name, alongside `didYouMean`, on both single-tool and `batch-*` error entries. A miss against a shell — for example the Fabric API umbrella JAR — no longer needs a separate `resolve-artifact` call to find which inner module actually holds the class. The array appears only when the artifact carries an inventory, is never empty, and is capped at 64 entries.
15
+
16
+ ### Changed
17
+
18
+ - **A dependency's `artifactId` is now derived from a sha256 of the jar's bytes**, replacing a signature built from HTTP freshness headers (sources jars) or local file modification time and size (binary jars) — neither of which reflects the actual content. Each already-cached dependency gets a new `artifactId` and re-indexes once on its first resolve after upgrading (including a fresh decompile for binary-only dependencies); ids you already have keep resolving.
19
+ - **A `-SNAPSHOT` coordinate is now re-checked against its repository on every resolve** instead of being pinned to its first download, since Maven allows republishing a `-SNAPSHOT` under the same name. The check is conditional (no transfer) when the cached copy carries an `ETag` or `Last-Modified`, and an ordinary request otherwise; a republished jar replaces the cached one and gets a new `artifactId`. If the check cannot complete — timeout, server error, rate limit, or similar — the cached bytes are served rather than failing the resolve; a definitive not-found or forbidden response moves resolution to the next configured repository instead. Release versions, and `-SNAPSHOT` jars served from `~/.m2` or the Gradle cache, are unaffected.
20
+ - **A binary-only dependency resolved from local disk now reports different fields.** A coordinate whose module publishes no sources jar anywhere, but whose binary jar is already in `~/.m2` or the Gradle cache, is now answered from that local jar (see Fixed) with `origin: "local-m2"` and a `binaryJarPath` pointing at the local jar, instead of `origin: "decompiled"` with a downloaded copy.
21
+ - **The decompiled-source warning on `get-class-source` now follows whether the source text was actually decompiled**, not the artifact's `origin` label. The only case this changes is the binary-only-from-local-disk dependency above: its text is decompiled even though its origin names where the jar came from, so it now correctly carries the warning.
22
+ - **`manage-cache` now counts each downloaded jar's small on-disk freshness record into that jar's own cache entry** instead of surfacing it as a separate entry, so cache-size and entry-count reporting no longer double-counts it.
23
+ - **Wire contract change.** A `get-class-members` failure on an artifact with no binary jar now reports `issueOrigin: "tool_issue"` when the tool itself picked that artifact (from a version or coordinate), instead of always reporting `"code_issue"` — since nothing in that kind of request lets the caller pick a different jar. A caller who named the artifact directly (an explicit `artifactId` or `target: { kind: "jar", ... }`) still gets `"code_issue"` for a missing binary jar. `verify-mixin-target` gets the same reclassification (see Fixed); `batch-class-members` does not yet. Clients that branch on `issueOrigin` should read it from each response rather than assume a fixed value per error code.
24
+
25
+ ### Fixed
26
+
27
+ - `get-class-members` and `batch-class-members` now answer a dependency target whose module publishes no sources jar, when its binary jar is already in the local Gradle cache — previously that binary jar was discarded and the read failed with `ERR_CONTEXT_UNRESOLVED`, even though `get-class-source` could read the same target. Bytecode-backed reads now work for binary-only modules from both the Gradle cache and `~/.m2`.
28
+ - Resolving the same dependency coordinate twice no longer re-downloads its jar or mints a new `artifactId`; a cached jar is reused and identified by the sha256 of its bytes, so the id is stable from the first resolve onward.
29
+ - A class-not-found hint no longer blames Minecraft obfuscation for artifacts that were never obfuscated. Dependency and Jar-in-Jar-shell misses previously advised remapping with `mapping="mojang"` even when the caller had already passed it, or when the artifact held no classes of its own; the hint is now suppressed for those artifacts, and whenever the request already named a non-obfuscated mapping. Vanilla artifacts genuinely indexed in obfuscated names still get the hint.
30
+ - `validate-project` with `task="mixin"` and no `version` no longer suggests a Minecraft version it never derived. The error previously carried a directly re-executable recovery call hardcoding `1.21.10`, so replaying it validated a project's mixins against the wrong version. It now points to `list-versions` and uses a `<your-mc-version>` placeholder naming where the real value lives (`gradle.properties`, or `task="project-summary"` with `preferProjectVersion: true`).
31
+ - `get-class-members` and `batch-class-members` now answer a coordinate whose module ships no sources jar anywhere, when a readable binary jar already sits in `~/.m2` or the Gradle cache — previously that local jar was discarded and the same bytes were downloaded again, or the call failed outright if no repository had it either. `~/.m2` and the Gradle cache are both tried, so a damaged copy in one does not hide a good copy in the other; a caller passing `allowDecompile: false` is still refused, since serving that artifact means decompiling it.
32
+ - A repository answering with something that is not a jar (an HTML error page, a truncated response) no longer becomes the resolved artifact — such a response is now checked as a readable archive before being accepted, on both a fresh download and a cache hit, so a cache already poisoned by an earlier version heals itself on the next resolve. A response with no body is likewise refused rather than cached.
33
+ - Reading an error through a `mc://` resource URI now reports the same `issueOrigin` as the equivalent tool call, instead of always reporting `"code_issue"` for a failure the tool call itself classifies as `"tool_issue"`.
34
+ - `verify-mixin-target` no longer blames the caller when the tool itself resolved an artifact with no binary jar from a `target: { kind: "version", ... }`; it now reports `issueOrigin: "tool_issue"` with guidance naming the two ways forward — re-target at a jar path directly, or use a version whose artifact ships one. A caller who passed `target: { kind: "jar", ... }` directly still gets `"code_issue"` for a missing binary jar.
35
+
10
36
  ## [7.0.0-rc.0] - 2026-08-22
11
37
 
12
38
  ### Added
13
39
 
14
- - MCP protocol revision `2026-07-28` (modern stateless era), served alongside the legacy initialize protocol in one process: `server/discover` returns `supportedVersions: ["2026-07-28"]`, capabilities, and the server identity; modern requests carry per-request `io.modelcontextprotocol/*` `_meta` (protocol version, client capabilities, optional client info) that survives the supervisor/worker boundary, queueing, and worker restarts; every modern result — including supervisor-synthesized overflow/restart/timeout replies — carries `resultType: "complete"` and the server-identity `_meta` echo; the cacheable methods (`tools/list`, `resources/list`, `resources/read`, `resources/templates/list`, `server/discover`) return `ttlMs`/`cacheScope` per the adopted private-cache policy; unsupported modern protocol versions answer `-32022` with `data.supported`/`data.requested`. Era selection is a supervisor-owned one-way lock with machine-readable rejections (`data.kind: "era_conflict"` / `"missing_meta"`); modern worker restarts never replay `initialize`. Contract details: `docs/tool-reference.md` MCP Protocol Support. Verification: dual-era wire suites (`tests/stdio/stdio-supervisor-era-state.test.ts`, `stdio-supervisor-era-wire.test.ts`, `stdio-supervisor-era-lifecycle.test.ts`, `stdio-supervisor-era-context.test.ts`) plus modern-surface suites (`stdio-modern-*.test.ts`) in the full green suite.
15
- - SQLite and decompiler tuning knobs: `MCP_SQLITE_CACHE_KB` (page cache in KiB, default `8000`) and `MCP_SQLITE_MMAP_SIZE` (bytes, default `268435456`, `0` disables) tune the artifact-index database, which now also uses `temp_store=MEMORY`; `MCP_DECOMPILE_MAX_MEMORY_MB` (default `4096`) caps the Vineflower JVM heap the same way `MCP_REMAP_MAX_MEMORY_MB` caps remapping. Defaults preserve existing behavior on typical installations.
16
- - `MCP_MAX_FRAME_BYTES` bounds the JSON-RPC frame size accepted by the stdio supervisor and worker transport (default 64 MiB, clamped to at least 1 MiB). An oversized or `Content-Length`-abusive frame is rejected with a diagnostic naming the observed size and the configured limit, header sections are capped at 8 KiB, and line-delimited frames obey the same limit. A framing violation the reader cannot provably resynchronize past is FATAL: the session is terminated with a `supervisor.client_framing_fatal` diagnostic and exit code 1 rather than silently consuming later frames.
40
+ - Support for MCP protocol revision `2026-07-28`, served alongside the legacy `initialize` protocol by the same process, so modern and legacy clients can both connect. A modern client calls `server/discover` for `supportedVersions: ["2026-07-28"]`, the advertised capabilities and the server identity, then carries the protocol version, its client capabilities and optional client info in `io.modelcontextprotocol/*` `_meta` on every request. The cacheable methods (`tools/list`, `resources/list`, `resources/read`, `resources/templates/list`, `server/discover`) return `ttlMs` and `cacheScope` for private caching, and every modern result carries `resultType: "complete"` and the server-identity `_meta` echo, including the replies synthesized for queue overflow, worker restart and timeout. A connection selects one era and keeps it: mixing eras is refused with `data.kind: "era_conflict"` or `"missing_meta"`, and an unsupported modern version answers `-32022` with `data.supported` / `data.requested`. The legacy `initialize` protocol stays supported; see the SDK v2 entry under Changed for the five legacy wire differences this release does carry. See `docs/tool-reference.md` MCP Protocol Support.
41
+ - Memory tuning environment variables: `MCP_SQLITE_CACHE_KB` (artifact-index page cache in KiB, default `8000`) and `MCP_SQLITE_MMAP_SIZE` (bytes, default `268435456`, `0` disables) for the artifact index, which now also keeps temporary tables in memory (`temp_store=MEMORY`), and `MCP_DECOMPILE_MAX_MEMORY_MB` (default `4096`) to cap the Vineflower JVM heap the way `MCP_REMAP_MAX_MEMORY_MB` caps remapping. Defaults preserve existing behavior on typical installations.
42
+ - `MCP_MAX_FRAME_BYTES` bounds the JSON-RPC frame size the stdio transport accepts (default 64 MiB, minimum 1 MiB). Oversized frames are rejected with a diagnostic naming the observed size and the configured limit, header sections are capped at 8 KiB, and line-delimited frames obey the same limit.
17
43
 
18
44
  ### Changed
19
45
 
20
- - **Behavior change.** `get-class-members` (and `batch-class-members`) now inherit the resolved artifact's mapping when the call supplies no explicit `mapping`, matching what `get-class-source` already did. Previously an artifact resolved as mojang answered member reads in the OBFUSCATED namespace `ownerFqn: "dlp"` with fields `e, f, g` — which silently broke the documented `find-class` `get-class-source` `get-class-members` flow; the two tools returned different namespaces for the same artifact and the same absent argument. `context.mappingNamespace` and `minecraftVersion` no longer contradict `returnedNamespace`. An explicit `mapping` is still never overridden. Note the `get-class-members.mapping` schema description still reads "default obfuscated"; the advertised `inputSchema` bytes are pinned for legacy wire parity, so the artifact-target qualifier is documented in `docs/tool-reference.md` instead.
21
- - **Behavior change result counts differ.** `compare-versions` now lifts both jars' class lists into the mojang namespace before diffing, instead of diffing raw obfuscated jar entries. Obfuscated names are not stable across Minecraft versions, so the previous diff compared unrelated symbols: on the measured 1.21.10→1.21.11 pair the unfiltered result moved from `added 236 / removed 0 / unchanged 6386` to `added 616 / removed 380 / unchanged 6006`. Results now carry `classes.namespace` and `classes.packageFilter { matchedFrom, matchedTo }`; a filter that matches nothing emits an explicit warning instead of returning a zeroed result that reads as "no changes"; and an unavailable mapping degrades to the obfuscated namespace with a loud warning rather than silently.
22
- - **Behavior change a previously successful call now fails closed.** In `validate-access-widener` and `validate-access-transformer`, a `scope: "merged"` runtime resolution that would serve a jar from a DIFFERENT loader than the workspace is now refused with `ERR_CONTEXT_UNRESOLVED` instead of being answered. The refusal sits on those two tools' runtime-evidence guard the only path that carries the loader mismatch so `resolve-artifact` and `get-class-source` resolve exactly as before. Previously the fallback could serve a NeoForge 1.21.10 jar for a Fabric 1.21.11 request, and `validate-access-widener` then reported `valid: true` for a Fabric access widener validated against that NeoForge jar a false PASS. A same-loader version drift is still answered, now marked `approximate: true`. Fallback policy is otherwise unchanged.
23
- - **Behavior change — a different artifact is resolved.** `inspect-minecraft` now hands a workspace subject to artifact resolution as `target.kind="workspace"`, exactly as `resolve-artifact` handles the same directory, instead of reading `minecraft_version` itself and resolving `target.kind="version"` with no mapping. The old path left `mapping` undefined, which normalizes to `obfuscated`; that flips the remap gate, changes `mappingVariant`, and therefore changes the hash the artifact id is built from, and it also skips Loom source-jar discovery — so `inspect-minecraft` and `resolve-artifact` returned DIFFERENT artifacts for the same project directory. The visible symptom: `task="class-members"` looked a mojang FQCN up verbatim in an obfuscated jar and failed, while `batch-class-members` — which forwards the workspace target untouched — succeeded on the same class. Workspace resolution now picks up the project's detected compile mapping and loader scope, so a call that previously resolved an obfuscated vanilla artifact can now resolve the merged, mojang-mapped one and report a different `artifactId`. An explicit `subject.mapping` / `subject.scope` is still never overridden, and `WORKSPACE_TARGET_OFF=1` keeps the previous routing.
24
- - **Behavior change — a response field reports a different set.** `resolve-method-mapping-exact` now returns the strict candidates the verdict was actually computed from on `status: "resolved"` and `status: "ambiguous"` the two verdicts the strict subset decides instead of the wider name-matched list. `status: "not_found"` and `status: "mapping_unavailable"` deliberately keep reporting that wider name-matched list: no strict subset produced either verdict, and on a miss the near misses are the useful content. The verdict has always been decided by the strict subset, but the response was built from the raw lookup, so a caller could be shown a `confidence: 1, matchKind: "exact"` candidate on another owner, under another descriptor — sitting beside `status: "ambiguous"` with nothing to mark it as one the tool had already rejected. `candidates` and `candidateCount` therefore shrink on any query where the simple-name index contributed extra matches. On `status: "resolved"` the reported set is the single match, which makes `candidates` redundant with `resolvedSymbol`, so the default response projection omits it and returns `resolvedSymbol` with `candidateCount: 1`. Rejected candidates are accounted for by a warning on the ambiguous verdict that reports the counts BY REASON — how many were rejected for their owner and how many for their descriptor — because a single cause would name the wrong one; a `resolved` answer whose candidate count shrank carries no such accounting, and `find-mapping` still returns the unfiltered list. The ambiguous result also populates `ambiguityReasons[]`, which the type declared but this path never set; because `resolve-workspace-symbol` spreads this result for `kind: "method"`, that field now appears on its method-ambiguous responses too, and it is present only on `status: "ambiguous"` where it always holds at least one reason. No short-circuit was added: the tool still refuses to pick a winner, which is the whole point of the strict variant.
25
- - **Behavior change.** `resolve-method-mapping-exact` is now OWNER-strict: its strict filter requires the query's full advertised triple `owner + name + descriptor`, where it previously checked the descriptor alone. The tool takes a REQUIRED `owner` and sells itself as the strict variant of `find-mapping`, but the member index also keys every method under an OWNERLESS `<name><descriptor>` key, so a same-signature method on an unrelated class was pulled in at `matchKind: "simple-name"`, survived the descriptor filter, and forced `status: "ambiguous"`. On a two-owner fixture where the queried class declares exactly one matching method, the result moves from `ambiguous` with 2 candidates one of them `matchKind: "exact", confidence: 1` — to `resolved` naming that one method. The owner is compared after being projected along the same mapping path the candidates travelled, since the query owner arrives in the source namespace while candidate owners are declaring classes in the target namespace. The accepted cost: a method the owner INHERITS rather than declares moves from `ambiguous` to `not_found`, because the mapping formats record declarations and carry no class hierarchy. That case is not silent — the result carries a warning naming the queried owner, the classes that do declare the member, and `find-mapping` as the owner-agnostic lookup. Two methods on the SAME owner remain `ambiguous`.
26
- - **Behavior change — a different error code.** When a workspace subject's Minecraft version cannot be detected, `inspect-minecraft` now fails with `ERR_WORKSPACE_VERSION_UNRESOLVED`, which names the `projectPath` and carries `nextAction` plus a `suggestedCall`. Previously the tool degraded to an empty `artifactId` and the failure surfaced further down as `ERR_INVALID_INPUT` "Either artifactId or target must be provided." an error that described neither the cause nor the fix. `task="class-overview"` and `task="file"`, which answered this case with a `blocked` summary, now surface the typed error too.
27
- - **Wire contract change — both eras.** Advertised capabilities now carry `resources: { listChanged: false }` and `tools: { listChanged: false }`, where both were previously `true`. This changes the observable bytes of the legacy `initialize` result as well as the modern `server/discover` result; the key order (`resources` before `tools`) and the rest of the payload are unchanged, and no prompts, subscriptions, or logging capability is advertised. The `true` was the SDK's default for any registered tool or resource surface and was never true of this server: the tool and resource surface is fixed at process start by environment flags, `notifications/tools/list_changed` and `notifications/resources/list_changed` are never emitted, and `subscriptions/listen` answers `-32601` in both eras — so the flag advertised a stream no client could subscribe to and that would never have carried anything. A client that keyed list invalidation to the advertised flag should treat both lists as static for the lifetime of the process and re-read them only across a restart. `buildServer()` now passes an explicit `capabilities` option to override the SDK default; the suppression is deliberate and is NOT era-gated, so the two eras cannot disagree. Contract details: `docs/tool-reference.md` MCP Protocol Support. Pinned by `tests/stdio/stdio-modern-discover-contents.test.ts` (modern `server/discover`) and `tests/stdio/stdio-dependency-method-inventory.test.ts` (legacy `initialize`, over the real wire).
28
- - Runtime migrated from `@modelcontextprotocol/sdk` 1.27.1 (SDK v1) with Zod 3 to `@modelcontextprotocol/server` 2.0.0 / `@modelcontextprotocol/client` 2.0.0 (SDK v2) with Zod 4.4.3, both exact-pinned. Legacy clients keep byte-compatible wire behavior: advertised `inputSchema` bytes are pinned to the pre-migration snapshots (`src/v1-parity-schemas.ts`, `tests/fixtures/premigration/tool-contracts/`), `runTool()` remains the sole tool-argument validator through an identity registration adapter (`src/registration-adapter.ts`, no SDK-private patching — guarded by `tests/contracts/no-sdk-private-request-handler-access.test.ts`), Zod error bytes keep zod3 parity (`tests/utils/zod3-parity.test.ts`), and initialization capture/replay across worker restarts is unchanged. Five recorded legacy exceptions: (1) `tools/call` with omitted `arguments` reaches the application validator as `{}` input-free tools succeed, required-field schemas answer per-field `ERR_INVALID_INPUT`, and a `{}`-accepting schema with an input-requiring handler answers that tool's handler-level ProblemDetails (`json-to-nbt` `ERR_NBT_INVALID_TYPED_JSON`); (2) legacy `tools/list` entries omit the v1-only `execution: {"taskSupport":"forbidden"}` field (SDK v2 does not emit it; all other advertised fields identical); (3) unknown/disabled-tool replies are synthesized at the supervisor pre-queue with identical bytes but no queue-slot consumption, so reply ordering and overflow outcomes can differ from v1 under concurrent load; (4) the unmatched-resource-URI error keeps code `-32602` but its message changed from `MCP error -32602: Resource <uri> not found` to `Resource not found: <uri>` with a `data.uri` field (SDK v2 wording); (5) the `initialize` result advertises `resources: { listChanged: false }` and `tools: { listChanged: false }`, where the pre-migration server advertised `true` for both the suppression is deliberate, and it is not era-gated because both eras read one `getCapabilities()` and a per-era split would leave one process advertising two contracts.
29
- - Modern-era `tools/list` returns raw tool-name-ascending order; legacy processes keep the frozen pre-migration registration order (goldens: `tests/fixtures/premigration/tools-list-order.*.json`).
30
- - **Breaking (Node package surface).** The exported `server` is now an SDK v2 `McpServer` with a different API surface than the v1 instance it replaces, `buildServer` takes an optional `McpRequestContext` and branches on `ctx?.era`, and the emitted `dist/index.d.ts` resolves Zod 4 types. Consumers importing this package as a library rather than speaking to it over stdio must migrate; the byte-compatibility guarantee above covers the WIRE surface only.
31
- - **Breaking (minimum runtime).** `engines.node` is raised from `>=22` to `>=22.13.0`. `src/storage/sqlite.ts` imports `node:sqlite` unguarded at module load, on the static import path the `dist/cli.js` bin reaches before it serves anything, and `src/storage/symbols-repo.ts` calls `StatementSync.iterate`. **22.13.0** is the first release where either is usable unflagged: `node:sqlite` landed in 22.5.0 but stayed behind `--experimental-sqlite` until 22.13.0 unflagged it, and 22.13.0 is also where `StatementSync.prototype.iterate` was added. Nothing in this package passes that flag — the bin is a plain `#!/usr/bin/env node` — so on 22.0–22.12 the unguarded import throws at process start and the server never reaches a request, rather than degrading; on 22.13.0 and later both the module and the iterator are present. The previous `>=22` floor understated the real requirement.
32
- - `provenance.mappingArtifact` for Loom-sourced mappings now names the file actually merged rather than the alphabetically first discovered file (for the measured 1.21.10 cache, `.../mappings-mojang.tiny` instead of `intermediary-v2.tiny`).
33
- - Modern-era `resources/read` for a URI that matches a registered template but has no backing artifact now answers JSON-RPC `-32602` (the revision requires an error for a resource that does not exist) instead of a successful `resultType: "complete"` carrying a ProblemDetails document. Applies to the five 404-class AppError codes. Legacy replies keep the ProblemDetails envelope and its cache-field absence.
46
+ - **Breaking Node.js 22.13.0 or newer is now required.** `engines.node` is raised from `>=22` to `>=22.13.0`, the first release where `node:sqlite` works unflagged and `StatementSync.iterate` exists. On Node 22.0–22.12 the server fails at startup rather than degrading, so upgrade Node.js before installing. The previous `>=22` floor understated the real requirement.
47
+ - **Breakingimporting this package as a Node library requires migration.** The exported `server` is now an SDK v2 `McpServer` with a different API surface, `buildServer` takes an optional `McpRequestContext`, and the published type declarations resolve Zod 4 types. Clients that talk to the server over stdio are unaffected.
48
+ - The runtime moved from MCP SDK v1 (`@modelcontextprotocol/sdk` 1.27.1, Zod 3) to SDK v2 (`@modelcontextprotocol/server` and `@modelcontextprotocol/client` 2.0.0, Zod 4.4.3). Legacy clients keep byte-compatible wire behavior advertised `inputSchema` bytes, argument validation and validation-error bytes are unchanged with five exceptions: (1) `tools/call` with `arguments` omitted now reaches the tool as `{}`, so input-free tools succeed, schemas with required fields answer per-field `ERR_INVALID_INPUT`, and a tool whose schema accepts `{}` but whose handler needs input answers its own error (`json-to-nbt` `ERR_NBT_INVALID_TYPED_JSON`); (2) `tools/list` entries no longer carry the v1-only `execution: {"taskSupport":"forbidden"}` field; (3) unknown and disabled tool replies no longer consume a queue slot, so reply ordering and overflow outcomes under concurrent load can differ; (4) the unmatched-resource-URI error keeps code `-32602` but its message changed from `MCP error -32602: Resource <uri> not found` to `Resource not found: <uri>`, with the URI also in `data.uri`; (5) `initialize` advertises `resources: { listChanged: false }` and `tools: { listChanged: false }`, where it previously advertised `true` for both.
49
+ - **Wire contract change — both eras.** Advertised capabilities now carry `resources: { listChanged: false }` and `tools: { listChanged: false }`; both were previously `true`. The tool and resource surface is fixed at process start, the server never emits list-changed notifications, and `subscriptions/listen` is rejected in both eras, so the old `true` advertised a stream that could never carry anything. Treat both lists as static for the lifetime of the process and re-read them only across a restart.
50
+ - **Behavior change.** `get-class-members` and `batch-class-members` now inherit the resolved artifact's mapping when the call supplies no explicit `mapping`, matching `get-class-source`. Previously a mojang-mapped artifact answered member reads in the obfuscated namespace — `ownerFqn: "dlp"` with fields `e, f, g` so the documented `find-class` `get-class-source` `get-class-members` flow returned two different namespaces for the same artifact. `context.mappingNamespace` and `minecraftVersion` no longer contradict `returnedNamespace`, and an explicit `mapping` is still never overridden. The `get-class-members.mapping` schema description still reads "default obfuscated" because the advertised schema bytes are pinned for legacy compatibility; the actual rule is documented in `docs/tool-reference.md`.
51
+ - **Behavior change — result counts differ.** `compare-versions` now diffs both jars in the mojang namespace instead of comparing raw obfuscated entries, which are not stable across Minecraft versions and made the old diff compare unrelated symbols. On the measured 1.21.10→1.21.11 pair the unfiltered result moved from `added 236 / removed 0 / unchanged 6386` to `added 616 / removed 380 / unchanged 6006`. Results carry `classes.namespace` and `classes.packageFilter { matchedFrom, matchedTo }`; a filter that matches nothing now warns instead of returning a zeroed result that reads as "no changes", and an unavailable mapping degrades to the obfuscated namespace with a loud warning.
52
+ - **Behavior change — a previously successful call now fails.** `validate-access-widener` and `validate-access-transformer` refuse a `scope: "merged"` resolution that would serve a jar from a different loader than the workspace, answering `ERR_CONTEXT_UNRESOLVED`. Previously a Fabric 1.21.11 request could be answered with a NeoForge 1.21.10 jar, and `validate-access-widener` then reported `valid: true` for a Fabric access widener checked against that jar a false PASS. Same-loader version drift is still answered, now marked `approximate: true`. `resolve-artifact` and `get-class-source` are unaffected.
53
+ - **Behavior change — a different artifact is resolved.** `inspect-minecraft` now resolves a workspace subject exactly as `resolve-artifact` does for the same directory, instead of reading `minecraft_version` itself and resolving without a mapping. The two tools previously returned different artifacts for one project directory: `task="class-members"` failed to find a mojang class name that `batch-class-members` found on the same class. A call that used to resolve an obfuscated vanilla artifact can now resolve the merged, mojang-mapped one and report a different `artifactId`. An explicit `subject.mapping` or `subject.scope` is still never overridden, and `WORKSPACE_TARGET_OFF=1` restores the previous routing.
54
+ - **Behavior change a response field reports a different set.** `resolve-method-mapping-exact` now reports the strict candidates its verdict was computed from on `status: "resolved"` and `"ambiguous"`, so `candidates` and `candidateCount` shrink where the simple-name index contributed extra matches. A caller can no longer be shown a `confidence: 1, matchKind: "exact"` candidate on another owner or descriptor next to an `ambiguous` verdict. `status: "not_found"` and `"mapping_unavailable"` keep the wider list, where the near misses are the useful content. `resolved` responses omit `candidates` and return `resolvedSymbol` with `candidateCount: 1`; `ambiguous` responses gain a warning counting how many candidates were rejected by owner and how many by descriptor, plus `ambiguityReasons[]`, which now also appears on `resolve-workspace-symbol` method-ambiguous responses. `find-mapping` still returns the unfiltered list.
55
+ - **Behavior change.** `resolve-method-mapping-exact` is now owner-strict: it requires the full `owner + name + descriptor`, where it previously filtered on the descriptor alone and could pull in a same-signature method from an unrelated class, forcing a false `ambiguous`. Queries whose owner declares exactly one matching method now resolve. The tradeoff: a method the owner **inherits** rather than declares moves from `ambiguous` to `not_found`, because mapping files record declarations and carry no class hierarchy. That case returns a warning naming the queried owner, the classes that do declare the member, and `find-mapping` as the owner-agnostic lookup. Two methods on the same owner remain `ambiguous`.
56
+ - **Behavior change a different error code.** When a workspace subject's Minecraft version cannot be detected, `inspect-minecraft` now fails with `ERR_WORKSPACE_VERSION_UNRESOLVED`, naming the `projectPath` and carrying `nextAction` and a `suggestedCall`. Previously it surfaced as `ERR_INVALID_INPUT` "Either artifactId or target must be provided.", which described neither the cause nor the fix. `task="class-overview"` and `task="file"`, which answered with a `blocked` summary, now return the typed error too.
57
+ - Modern-era `resources/read` for a URI matching a registered template with no backing artifact now answers JSON-RPC `-32602`, as the revision requires, instead of a successful `resultType: "complete"` result carrying a ProblemDetails document. Applies to the five 404-class error codes. Legacy replies keep the ProblemDetails envelope.
58
+ - Modern-era `tools/list` returns tools in name-ascending order; legacy connections keep the previous registration order.
59
+ - `provenance.mappingArtifact` for Loom-sourced mappings names the file actually merged rather than the alphabetically first one found (for the measured 1.21.10 cache, `mappings-mojang.tiny` instead of `intermediary-v2.tiny`).
34
60
 
35
61
  ### Fixed
36
62
 
37
- - The published tarball no longer risks shipping internal design documents. `files` listed `docs/**/*.md`, and because an npm `files` allowlist overrides `.gitignore`, a publish run from a working tree that contained `docs/plans/`, `docs/specs/`, or `docs/reports/` would have included them 19 files and about 840 kB on the tree this was measured from, among them the implementation plan for this very release. The CI publish path was never exposed (it publishes from a clean `actions/checkout`), but a local `npm publish` was. `files` now names the three intended documents explicitly, so a newly added internal document cannot leak by default.
38
- - The npm release path can no longer publish a prerelease under the `latest` dist-tag. The publish workflow derives the dist-tag from the package version a SemVer prerelease publishes under `rc`, anything else under `latest` fails before publishing when the pushed git tag and the package version disagree, and refuses to run when the frozen named-test set gate's escape hatch is present in the environment.
39
- - Runtime provenance no longer claims a version or loader that was not served. `provenance` now describes the jar actually used `version`, `requestedVersion`, `versionApproximated`, `servedLoader`, `expectedLoader`, `loaderMismatch` where it previously echoed the REQUESTED version back to the caller, so a substituted artifact was indistinguishable from an exact match. `resolve-artifact` gains `provenance.versionApproximation` and corrects `resolvedFrom.version`.
40
- - `get-artifact-file` serves root-level and `META-INF/**` jar entries. Delivery was gated on an `assets/`/`data/` prefix with no safety purpose, so `fabric.mod.json`, `META-INF/MANIFEST.MF`, `<mod>.mixins.json`, and license files all answered `ERR_FILE_NOT_FOUND` despite being present in the jar — precisely the files an agent inspecting a mod reads first. Extension-less entries are now classified by sniffing for valid UTF-8 rather than assumed binary. The 512 KiB cap, the `truncated` flag, the `contentOmittedReason` for binary entries, and `ERR_INVALID_INPUT` for traversal-shaped paths are unchanged.
41
- - `validate-access-transformer` resolves its context from a NeoForge/ModDevGradle workspace. Candidate discovery required an exact Minecraft-version token in the artifact path, but ModDevGradle names artifacts after the LOADER version (`neoforge-21.11.38-beta-*.jar` for Minecraft 1.21.11), so every candidate was dropped and the canonical NeoForge example project answered `ERR_CONTEXT_UNRESOLVED` even with `projectPath` supplied. Loader-to-Minecraft version equivalence is now recognized and recorded as a provenance note, and resources-only `client-extra` jars are deprioritized.
42
- - An error hint can no longer ask the caller to supply a parameter they already supplied. The `validate-access-transformer` hint was the observed instance; the fix is applied generically to execution-error guidance so any tool's hints drop asks the request already satisfied.
43
- - The typed-NBT rejections say what is wrong and what to do about it. `ERR_NBT_INVALID_TYPED_JSON` published `code`, `detail`, `status`, `retryClass`, and `issueOrigin` and nothing else: its `jsonPointer` / `expectedType` / `actualType` were stripped by the context allowlist, and there were no `fieldErrors`, no `hints`, and no `suggestedCall` — so a caller was told "Invalid typed NBT JSON document." with no way to learn WHICH node, and the `typedJson` argument advertises an empty JSON Schema (`{}`, frozen for legacy parity) that cannot tell them either. The error now carries `fieldErrors[0].path` (the RFC6901 pointer into the document, or `typedJson` for a root-level failure) plus a `nextAction` naming the expected and received types and the document shape, and `json-to-nbt` / `nbt-apply-json-patch` attach an `exampleCalls` entry pointing at `nbt-to-json` as the reliable way to obtain a valid document. `ERR_NBT_PARSE_FAILED`, `ERR_NBT_ENCODE_FAILED`, `ERR_JSON_PATCH_INVALID`, `ERR_NBT_UNSUPPORTED_FEATURE`, and `ERR_JSON_PATCH_CONFLICT` gain default `nextAction` guidance the same way. No message string and no advertised schema changed; the required document shape is documented in `docs/tool-reference.md` instead.
44
- - `docs/tool-reference.md` no longer states a condition on the suggested-call fallback hint that the code does not implement. It documented `"suggested call payload failed schema validation; using fallback examples"` as firing only when a primary suggestion is dropped AND no `exampleCalls[]` fallback exists; neither emitting branch checks that, and all six recorded tool-contract envelopes that carry the sentence also carry `exampleCalls`. The sentence also fires for a deliberate placeholder-template drop, where nothing was ever schema-validated. The wording itself is unchanged and now carries a note saying why: it is frozen byte-for-byte in ten pre-migration fixtures that cannot be re-recorded.
45
- - `get-class-source`'s `ERR_CLASS_NOT_FOUND` names the artifact the caller asked about. When the class was missing, an internal binary fallback re-resolved a DIFFERENT artifact and the error was then written from it: `details.artifactId`, `error.context.artifactId` and `details.suggestedCall.params.artifactId` all named an artifact the caller never requested, and `details.didYouMean` was collected from that artifact's symbol index — so the near-miss candidates came from the wrong jar and a `find-class` retry built from the suggestion searched the wrong place. The requested id is reported again; the fallback id survives as an additive `details.fallbackArtifactId`, present only when the two differ. `details.didYouMean` is collected from the requested artifact's index FIRST and then unioned with the fallback artifact's (see the near-miss entry under Fixed). A SUCCESSFUL fallback still reports the fallback artifact, which is where the returned source actually came from. Separately, the fallback now honors `allowDecompile` instead of hardcoding `true`, so a caller who explicitly passed `allowDecompile: false` no longer pays a full Vineflower pass (measured on a first miss: 22.5 s decompile plus 29.3 s indexing). That cost is UNCHANGED for callers who did not decline decompilation — the fallback is not otherwise narrowed.
46
- - `ERR_CLASS_NOT_FOUND` offers near-miss candidates in the one scenario they were built for. Collecting `details.didYouMean` from the requested artifact alone returned `[]` exactly when the partial-source binary fallback had fired, because the requested artifact is by construction the one WITHOUT `net.minecraft` symbols — which is why the fallback fired and indexed the other jar. A typo'd vanilla class name therefore lost its only recovery signal. Candidates are now taken from the requested artifact first and unioned with the artifact the lookup ended on, deduplicated by fully-qualified name; a candidate found outside the requested artifact carries an additional `artifactId` naming where it was found, so an entry without that field always means the artifact the caller asked about. The identity fields are unchanged: `details.artifactId`, `error.context.artifactId` and `suggestedCall.params.artifactId` still name the REQUESTED artifact.
47
- - A `get-class-source` failure that followed a nested-jar redirect no longer describes two artifacts at once. The redirect into a shell jar's bundled inner jar replaced the active artifact id, mapping and quality flags together, so the resulting `ERR_CLASS_NOT_FOUND` reported the outer shell as `details.artifactId` while `details.mapping` and `details.qualityFlags` described the inner jar and `mapping` is published through the context allowlist, so a client acting on it queried the wrong namespace, with a `suggestedCall` pointing `find-class` at an index holding neither the class nor its siblings. All three fields now describe the artifact the caller named; `details.fallbackArtifactId` continues to name where the lookup ended, for the nested-jar redirect as well as the binary fallback.
48
- - `docs/tool-reference.md` no longer promises that `error.exampleCalls[]` entries are "always-valid" and "safe to re-call as-is". Examples are validated for schema SHAPE only and are explicitly permitted to be templates, and frozen envelopes already publish `<...>` placeholders that would be rejected if replayed verbatim. The guarantee is corrected to what the code provides — right tool, right argument names and types, placeholders possible — with no change to the emitted examples or to any fixture. The same section's `didYouMean` and `resolve-method-mapping-exact` descriptions are updated for the behavior changes above.
49
- - `find-class` reports a nested type under its own name. A nested class or enum was reported under its OUTER fully-qualified name because the symbol extractor stores one `qualifiedName` per file, so searching `Block` returned `net.minecraft.world.level.ClipContext` (the outer class of `ClipContext.Block`) ranked above the real `net.minecraft.world.level.block.Block`. Nested matches now render as `<Outer>.<Nested>` with `nested` and `enclosingClass` fields, and top-level matches sort first. A zero-result response classifies its warning as `partial_coverage` and carries a `suggestedCall` to `get-class-source` in one specific case — when the resolved artifact carries the `partial-source-no-net-minecraft` quality flag AND the query looks like a deobfuscated class name, the combination where partial coverage is what makes the index unable to answer while the binary fallback still can. Every other empty result is unchanged.
50
- - `find-mapping` and the other mapping lookups no longer exhaust the worker heap on a populated Fabric Loom cache. Every `.tiny` file discovered for a version under `<gradleUserHome>/caches/fabric-loom/<version>` was read whole and merged into one unbounded index; on a real 1.21.10 cache that is 13 files totalling 87,744,824 bytes, which drove the worker into `Ineffective mark-compacts near heap limit`, `SIGABRT`, and an `ERR_WORKER_RESTART` for the caller. The loader now selects files from a 64 KiB header probe, deduplicates identical contents (those 13 files are only 6 distinct contents), merges into a shared accumulator instead of building a per-file index first, and bounds the accumulation with a heap-derived budget overridable by `MCP_LOOM_TINY_MAX_INDEX_ENTRIES`. Measured on that cache: 4,256.7 MB peak and a crash at 86.5 s became 2,705.3 MB and a resolved answer at 26.1 s, reading 31.0 MB of the 83.7 MB.
51
- - Loom mapping lookups no longer report phantom candidates from a descriptor-namespace mismatch. Tiny v2 stores each descriptor once in the file's FIRST namespace, and the parser stamped that column onto every namespace untranslated; because `mappings-base.tiny` is headed `intermediary named official` while the sibling renderings are official-first, merging it re-registered every method under a second, foreign-coordinate descriptor — 216,696 rows on the measured cache, all methods. A method query could therefore return two candidates for one real member, and the phantom could outrank the real record in candidate ordering. Selection now drops a rendering only when it uses a different descriptor namespace, sits in the same directory as an already-selected rendering, and adds no uncovered namespace pair. One measured `resolve-method-mapping-exact` query changed from `resolved: false, status: "ambiguous"` with 2 candidates to `resolved: true` with 1. That is the same shape as the owner-strict change but a different cause and a different second candidate: owner-strictness drops a real method declared by an unrelated class that the member index admitted under its OWNERLESS key, while this drops a phantom of the queried method itself, re-registered under a foreign-coordinate descriptor.
52
- - Modern-era `subscriptions/listen` is rejected at supervisor admission with `-32601` `Method not found`, as the contract always documented, and `maxSubscriptions: 0` is passed to `serveStdio` as defense in depth. Previously a conformant listen carrying a valid `SubscriptionFilter` was SERVED: the SDK answered with an id-less `notifications/subscriptions/acknowledged` notification and never settled the request id, so the supervisor's pending entry survived forever — permanently blocking the `validate-project` barrier and head-of-line-blocking every queued request. Because `notifications/cancelled` released the SDK subscription but not the supervisor entry, repeated listen/cancel pairs also grew `pendingRequests` without bound while evading the SDK's own 1024-subscription cap.
53
- - A cancelled request now releases its supervisor slot for every method, not only `validate-project`: `notifications/cancelled` settles the pending entry, clears its deadline timer, releases the `validate-project` barrier if it held one, and records an ordinary response-finality tombstone so a late worker answer is still discarded. An `initialize` in flight is excluded, since its handshake lifecycle owns that entry. Finality tombstones are capped with insertion-order eviction so the bookkeeping cannot itself grow without bound.
54
- - An `initialize` request that is not a valid MCP initialize request no longer locks the process to the legacy era. Previously any generically valid JSON-RPC frame named `initialize` committed the one-way era lock BEFORE method-schema validation, so a malformed handshake permanently burned the era choice: the client received a `-32603` restart error implying a transient failure, and every later modern request answered `-32600` `era_conflict` until the process was respawned. The request is now validated against the SDK's own initialize schema first and rejected with `-32602` `data.kind: "invalid_initialize"` (carrying `data.required[]` and `data.eraSelected: false`), leaving the era unselected and the client free to retry either era.
55
- - The modern `protocolVersion` VALUE is validated on every modern request instead of only on the one that pins the connection. Previously, once a connection was pinned by a valid request, later requests carrying an unsupported version were served normally — a `tools/call` with `"1999-12-31"` executed its handler and whether `-32022` fired at all depended on which method the client happened to send first.
56
- - A framing violation that the reader cannot provably resynchronize past now terminates the session with a `supervisor.client_framing_fatal` diagnostic and exit code 1, instead of silently consuming every later frame. Previously a 29-byte `Content-Length: 999999999\r\n\r\n` header with no body left the reader waiting on the attacker-declared count for the process lifetime, so all subsequent valid frames were swallowed with no further diagnostic; an under-declared `Content-Length` likewise corrupted the next valid request by prefixing it with the unread remainder. Duplicate `Content-Length` headers are rejected rather than resolved last-wins.
57
- - Line framing recovers after a line-delimited JSON array frame. The Content-Length reader recognized only `{` as an opener that cannot begin a header block, so an array arriving after a Content-Length frame was neither consumed as a line nor parsed as a header, and every subsequent frame stayed buffered indefinitely with no parse error reported. Arrays are re-dispatched only to surface their JSON-RPC schema error; this does not add batch-message support.
58
- - A `server/discover` admitted while the worker was starting is no longer drained into a `-32601` by a later `initialize`. The queued arrival-order prefix preceding an `initialize` is now forwarded ahead of it; the suffix stays gated until the initialize response.
59
- - Legacy `tools/call` with a non-object `arguments` reproduces the v1 error bytes again. Method-shape validation now runs before the unknown-tool intercept on legacy connections, so a malformed call naming a missing tool no longer returns a successful `isError` "not found" envelope, and a registered tool answers the v1 raw `-32603` rather than the SDK v2 `-32602`. Modern calls keep the SDK's `-32602`.
60
- - A supervisor synthetic terminal reply (queue overflow, cap-blocked restart, unknown-tool intercept, or queued-request terminalization) for a request that reuses a live JSON-RPC id no longer settles or tombstones the different live request sharing that id. Previously the live request's worker answer was tombstone-discarded and a running `validate-project` barrier could strand queued requests for the worker's lifetime. The same identity rule also drops an orphaned preserved initialize entry when a cap-blocked re-initialize discards the cached handshake, instead of leaking it into `pendingRequests`. Pinned by `tests/stdio/stdio-supervisor-finality-id-reuse.test.ts`.
61
- - A legacy `initialize` carrying a valid modern `io.modelcontextprotocol/*` era claim in `params._meta` completes the legacy handshake: the supervisor strips the era-claim keys before the frame reaches the worker, whose opening classifier would otherwise treat a claim-bearing `initialize` as modern and fail the handshake with a worker restart. Non-era `_meta` keys pass through. Pinned by the enveloped-initialize wire test in `tests/stdio/stdio-supervisor-era-wire.test.ts`.
62
- - Version manifest and version-detail fetches now abort after `MCP_FETCH_TIMEOUT_MS` with a typed `ERR_REPO_FETCH_FAILED` error instead of hanging indefinitely when a repository stops responding.
63
- - `manage-cache` opens the artifact index through the integrity-checking recovery path: a corrupt SQLite file is backed up and rebuilt instead of crashing cache inspection, and a missing database file is still not created as a side effect of inspection.
64
- - In supervised stdio mode, a worker that hits a fatal `uncaughtException`/`unhandledRejection` exits after logging so the supervisor restart/replay path replaces it; previously the faulted worker stayed alive with only `exitCode` set, bypassing recovery.
65
- - The stdio supervisor retries unresolved process-tree cleanup tokens during normal operation with capped exponential backoff, emitting `supervisor.cleanup_token.retry`/`supervisor.cleanup_token.recovered` events and unblocking the 2-slot live cap without waiting for shutdown. Live-cap saturation that blocks a restart is surfaced as `supervisor.live_cap.saturated`.
66
- - Tool responses keep their typed error envelopes when SQLite is unavailable: input validation still returns `ERR_INVALID_INPUT`, database-independent tools (such as the NBT utilities) still return their successful results, and a metrics-recording failure no longer replaces a completed response.
67
- - A supervised worker now stands down when its supervisor goes away, instead of surviving as an orphan for the lifetime of the machine. The worker held an unconditional keep-alive `setInterval` whose only `clearInterval` ran from a process `"exit"` listener — which by definition fires once the process is already leaving — so stdin EOF closed the MCP server but never drained the event loop: any launcher that terminated the server without a cooperative shutdown left a resident node process behind — measured at ~125 MB on the reference host — and each restart added another. The worker now releases the keep-alive on stdin EOF, remembers an EOF that arrives while it is still starting (a fast-closing pipe could otherwise deliver `end` before the listener existed), and — as defense in depth for hosts whose stdin is not a pipe the parent solely owns — stands down when the parent pid it snapshotted at startup stops existing. Teardown never calls `process.exit()`, so replies still being written after a half-close are delivered in full. On the supervisor side, a generation that signals readiness and then stands down cleanly before it has been ready for a second no longer counts as a start that succeeded: adoption still clears the restart backoff, and the clean stand-down then raises it back to the level the repeated failure has earned without reserving a restart, so a host that closes every spawned child's stdin immediately backs off exponentially instead of respawning a worker ten times a second. A crash keeps its prompt replacement. Measured on the reference host, a single full `npm test` run each way: 20 resident orphaned workers were left before the fix and 0 after. Test-side teardown moved onto a shared stdin-close → `SIGTERM` → `SIGKILL` ladder (`tests/helpers/stdio-child-lifecycle.ts`), and `tests/contracts/no-direct-sigkill.test.ts` keeps it that way.
68
- - The stdio supervisor now reaps its worker on `SIGHUP` and on its own fatal errors, instead of leaving the detached worker process group with nobody to collect it. `SIGHUP` — what a terminating launcher or a vanishing session sends — was never registered, so the OS default terminated the supervisor where it stood; an uncaught exception or unhandled rejection in the supervisor process was likewise left to node's default handler, which prints a stack and exits without any route to shutdown. Both now run the supervisor's ordinary shutdown path, which terminates the worker process group; a fatal error is reported as a `supervisor.fatal` event and then ends the process explicitly with a non-zero status, once the worker group has been collected or a bounded watchdog expires. Registering fatal handlers suppresses node's default abort, so that exit can no longer be left to the event loop draining on its own, and the handlers now stay registered until the teardown tail finishes rather than being withdrawn before it.
63
+ - Runtime provenance no longer claims a version or loader that was not served. `provenance` describes the jar actually used `version`, `requestedVersion`, `versionApproximated`, `servedLoader`, `expectedLoader`, `loaderMismatch`where it previously echoed the requested version back, making a substituted artifact indistinguishable from an exact match. `resolve-artifact` gains `provenance.versionApproximation` and corrects `resolvedFrom.version`.
64
+ - `get-artifact-file` serves root-level and `META-INF/**` jar entries. Delivery was gated on an `assets/` or `data/` prefix, so `fabric.mod.json`, `META-INF/MANIFEST.MF`, `<mod>.mixins.json` and license files answered `ERR_FILE_NOT_FOUND` despite being in the jarprecisely the files you read first when inspecting a mod. Extension-less entries are now classified by checking for valid UTF-8 instead of being assumed binary. The 512 KiB cap, `truncated` flag, `contentOmittedReason` and `ERR_INVALID_INPUT` for traversal-shaped paths are unchanged.
65
+ - `validate-access-transformer` resolves its context from a NeoForge/ModDevGradle workspace. Discovery required an exact Minecraft-version token in the artifact path, but ModDevGradle names artifacts after the loader version (`neoforge-21.11.38-beta-*.jar` for Minecraft 1.21.11), so the canonical NeoForge example project answered `ERR_CONTEXT_UNRESOLVED` even with `projectPath` supplied. Loader-to-Minecraft version equivalence is now recognized and recorded as a provenance note, and resources-only `client-extra` jars are deprioritized.
66
+ - Error hints no longer ask you to supply a parameter you already supplied. Observed on `validate-access-transformer`; fixed for execution-error guidance generally.
67
+ - Typed-NBT rejections now say what is wrong and what to do about it. `ERR_NBT_INVALID_TYPED_JSON` used to report only "Invalid typed NBT JSON document." with no way to learn which node was at fault. It now carries `fieldErrors[0].path` the RFC 6901 pointer into the document, or `typedJson` for a root-level failure plus a `nextAction` naming the expected and received types, and `json-to-nbt` / `nbt-apply-json-patch` attach an `exampleCalls` entry pointing at `nbt-to-json` as the reliable way to obtain a valid document. `ERR_NBT_PARSE_FAILED`, `ERR_NBT_ENCODE_FAILED`, `ERR_JSON_PATCH_INVALID`, `ERR_NBT_UNSUPPORTED_FEATURE` and `ERR_JSON_PATCH_CONFLICT` gain default `nextAction` guidance the same way. No message string and no advertised schema changed; the required document shape is documented in `docs/tool-reference.md`.
68
+ - `get-class-source`'s `ERR_CLASS_NOT_FOUND` describes the artifact you asked about. An internal fallback or a nested-jar redirect used to overwrite `details.artifactId`, `error.context.artifactId`, `details.suggestedCall.params.artifactId`, `details.mapping` and `details.qualityFlags` with a different artifact so a client acting on `mapping` queried the wrong namespace, and a `find-class` retry built from the suggestion searched an index holding neither the class nor its siblings. Those fields now describe the requested artifact; where the lookup ended is reported additively as `details.fallbackArtifactId`, present only when the two differ. A successful fallback still reports the fallback artifact, which is where the source came from.
69
+ - `ERR_CLASS_NOT_FOUND` offers near-miss candidates in the case they were built for. `details.didYouMean` came back empty exactly when the binary fallback had fired, so a typo'd vanilla class name lost its only recovery signal. Candidates are now taken from the requested artifact and unioned with the artifact the lookup ended on, deduplicated by fully-qualified name; a candidate found elsewhere carries an `artifactId` naming where it was found, so an entry without that field always means the artifact you asked about.
70
+ - `get-class-source`'s binary fallback honors `allowDecompile` instead of hardcoding `true`, so passing `allowDecompile: false` no longer costs a full Vineflower pass (measured on a first miss: 22.5 s decompile plus 29.3 s indexing). The cost is unchanged if you did not decline decompilation.
71
+ - `find-class` reports a nested type under its own name. A nested class or enum was reported under its outer fully-qualified name, so searching `Block` returned `net.minecraft.world.level.ClipContext` (the outer class of `ClipContext.Block`) above the real `net.minecraft.world.level.block.Block`. Nested matches now render as `<Outer>.<Nested>` with `nested` and `enclosingClass` fields, and top-level matches sort first. A zero-result response is classified as `partial_coverage` and suggests `get-class-source` when the resolved artifact carries the `partial-source-no-net-minecraft` flag and the query looks like a deobfuscated class name the one case where the index cannot answer but the binary fallback can.
72
+ - Mapping lookups no longer exhaust the worker heap on a populated Fabric Loom cache. Every `.tiny` file found for a version was read whole into one unbounded index; on a real 1.21.10 cache that meant 13 files totalling 87,744,824 bytes, which crashed the worker and returned `ERR_WORKER_RESTART`. Files are now selected from a header probe, duplicate contents are skipped, and accumulation is bounded by a heap-derived budget overridable with `MCP_LOOM_TINY_MAX_INDEX_ENTRIES`. Measured on that cache: a crash at 86.5 s with 4,256.7 MB peak became a resolved answer at 26.1 s with 2,705.3 MB.
73
+ - Loom mapping lookups no longer report phantom candidates. Merging Tiny v2 files whose namespace order differs re-registered every method a second time under a foreign descriptor, so a method query could return two candidates for one real member, and the phantom could outrank the real record. One measured `resolve-method-mapping-exact` query changed from `ambiguous` with 2 candidates to `resolved` with 1.
74
+ - `subscriptions/listen` is rejected with `-32601` `Method not found` in the modern era, as the contract always documented. A conformant listen request was previously accepted but never answered, which permanently blocked the `validate-project` barrier and every request queued behind it.
75
+ - A cancelled request now releases its slot for every method, not only `validate-project`. `notifications/cancelled` settles the request, clears its deadline, releases the `validate-project` barrier if it held one, and discards a late answer. An `initialize` in flight is excluded.
76
+ - A malformed `initialize` no longer locks the process to the legacy protocol. Any JSON-RPC frame named `initialize` used to commit the one-way era choice before validation, so a bad handshake returned a `-32603` restart error and made every later modern request fail with `era_conflict` until the process was respawned. Such a request is now rejected with `-32602` and `data.kind: "invalid_initialize"` (carrying `data.required[]` and `data.eraSelected: false`), leaving the era unselected and the client free to retry either era.
77
+ - The modern `protocolVersion` value is validated on every request instead of only the one that pins the connection. Previously a later request carrying an unsupported version was served normally a `tools/call` with `"1999-12-31"` ran its handler and whether `-32022` fired depended on which method you sent first.
78
+ - A framing violation the reader cannot safely recover from now ends the session with a `supervisor.client_framing_fatal` diagnostic and exit code 1, instead of silently swallowing everything that follows. A `Content-Length` header declaring a body that never arrives previously hung the reader for the process lifetime, and an under-declared length corrupted the next request. Duplicate `Content-Length` headers are rejected rather than resolved last-wins.
79
+ - Line framing recovers after a line-delimited JSON array frame. An array arriving after a `Content-Length` frame was neither consumed as a line nor parsed as a header, so every later frame stayed buffered indefinitely with no error reported. Arrays are now re-dispatched to surface their JSON-RPC schema error; this does not add batch-message support.
80
+ - A `server/discover` sent while the worker is still starting is no longer failed with `-32601` by a later `initialize`.
81
+ - Legacy `tools/call` with a non-object `arguments` returns the v1 error bytes again: a malformed call naming a missing tool no longer returns a successful "not found" envelope, and a registered tool answers `-32603` rather than `-32602`. Modern calls keep `-32602`.
82
+ - Reusing a JSON-RPC id while another request still holds it no longer costs that request its answer. A queue overflow, blocked restart or unknown-tool reply for the reused id used to settle the live request instead, discarding its result and stranding everything queued behind a running `validate-project`.
83
+ - A legacy `initialize` carrying a modern `io.modelcontextprotocol/*` era claim in `params._meta` now completes the legacy handshake instead of failing with a worker restart. Other `_meta` keys pass through unchanged.
84
+ - Version manifest and version-detail fetches abort after `MCP_FETCH_TIMEOUT_MS` with a typed `ERR_REPO_FETCH_FAILED` instead of hanging when a repository stops responding.
85
+ - `manage-cache` opens the artifact index through the integrity-checking recovery path: a corrupt SQLite file is backed up and rebuilt instead of crashing cache inspection, and a missing database is still not created as a side effect of inspection.
86
+ - Tool responses keep their typed error envelopes when SQLite is unavailable: input validation still returns `ERR_INVALID_INPUT`, database-independent tools such as the NBT utilities still return results, and a metrics-recording failure no longer replaces a completed response.
87
+ - The server recovers from a crashed worker instead of going unresponsive. A worker that hit a fatal `uncaughtException` or `unhandledRejection` used to stay alive with recovery bypassed; it now exits so the supervisor can replace it. A restart blocked by stale process-tree cleanup is retried with capped backoff instead of waiting for shutdown.
88
+ - The server no longer leaves orphaned processes behind. A keep-alive timer kept the worker's event loop running after stdin closed, so any launcher that terminated the server without a cooperative shutdown left a resident node process — about 125 MB on the reference host — and each restart added another. The worker now stands down on stdin EOF, and also when the parent process it recorded at startup disappears. Replies still being written after a half-close are delivered in full. A host that immediately closes every child's stdin now backs off exponentially instead of respawning a worker ten times a second, while a genuine crash still gets a prompt replacement. Measured on the reference host over one full test run each way: 20 orphaned workers before the fix, 0 after.
89
+ - Ending the session no longer leaves a worker process behind. `SIGHUP` what a terminating launcher or a vanishing session sends — was never handled, and an uncaught error in the supervisor went to node's default handler, so either one left the detached worker process group with nobody to collect it. Both now run the ordinary shutdown path, report a `supervisor.fatal` event where applicable, and exit non-zero once the worker group is collected or a bounded watchdog expires.
90
+ - The published package no longer risks shipping internal design documents. `files` listed `docs/**/*.md`, and because an npm `files` allowlist overrides `.gitignore`, a publish from a working tree containing local design notes would have included them. `files` now names the three intended documents explicitly.
91
+ - The automated npm release workflow can no longer publish a prerelease under the `latest` dist-tag: it derives the dist-tag from the package version, so a SemVer prerelease publishes under `rc` and an install without an explicit tag never picks up a release candidate.
92
+
93
+ ### Documentation
94
+
95
+ - `docs/tool-reference.md` no longer states a condition on the suggested-call fallback hint that the code does not implement. It documented `"suggested call payload failed schema validation; using fallback examples"` as firing only when a primary suggestion is dropped and no `exampleCalls[]` fallback exists; neither branch checks that, and the sentence also fires for a deliberate placeholder-template drop, where nothing was schema-validated. The wording itself is unchanged, because it is part of the frozen legacy wire surface, and now carries a note saying why.
96
+ - `docs/tool-reference.md` no longer promises that `error.exampleCalls[]` entries are "always-valid" and "safe to re-call as-is". Examples are validated for schema shape only and may be templates; frozen envelopes already publish `<...>` placeholders that would be rejected if replayed verbatim. The guarantee is corrected to what the code provides — right tool, right argument names and types, placeholders possible — with no change to the emitted examples. The `didYouMean` and `resolve-method-mapping-exact` descriptions in the same section are updated for the behavior changes in this release.
69
97
 
70
98
  ## [6.3.0] - 2026-07-18
71
99
 
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { mapWithConcurrencyLimit } from "./concurrency.js";
5
5
  import { createError, ERROR_CODES } from "./errors.js";
6
6
  import { normalizeOptionalPathForHost } from "./path-converter.js";
7
+ import { downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
7
8
  import { openDatabase } from "./storage/db.js";
8
9
  import { getProcessWorkspaceContextCache } from "./workspace-context-cache.js";
9
10
  export const PUBLIC_CACHE_KINDS = [
@@ -467,15 +468,28 @@ async function fileBackedEntries(config, cacheKind, detectCorruption) {
467
468
  }
468
469
  const root = kindRoot(config, cacheKind);
469
470
  const files = await listFilesRecursive(root);
470
- return mapWithConcurrencyLimit(files, CACHE_STAT_CONCURRENCY, async (filePath) => {
471
+ // A download sidecar (`<jar>.cache.json`, or the `.<hex>.tmp` leftover of an
472
+ // interrupted write) is the identity record of the jar beside it, not a cached
473
+ // artifact in its own right. Listing one would report a `downloads` entry whose
474
+ // jarPath is a JSON file and would let a jarPath selector delete a description
475
+ // instead of the thing described. The finished record's bytes are folded into
476
+ // the jar's entry below, so a listed entry weighs everything that belongs to
477
+ // it; bytes that describe nothing - an orphan record, a half-written one - are
478
+ // deliberately unaccounted, because there is no entry for them to belong to.
479
+ // Only this kind names files that way; every other kind keeps every file.
480
+ const entryFiles = cacheKind === "downloads"
481
+ ? files.filter((filePath) => !isDownloadSidecarPath(filePath))
482
+ : files;
483
+ return mapWithConcurrencyLimit(entryFiles, CACHE_STAT_CONCURRENCY, async (filePath) => {
471
484
  const fileStat = await stat(filePath);
485
+ const sidecarBytes = cacheKind === "downloads" ? await downloadSidecarSizeBytes(filePath) : 0;
472
486
  const normalizedEntryId = filePath.slice(root.length + 1);
473
487
  const inferredScope = inferScope(filePath, normalizedEntryId) ?? (cacheKind === "decompiled-source" ? "vanilla" : undefined);
474
488
  return {
475
489
  cacheKind,
476
490
  entryId: normalizedEntryId,
477
491
  path: filePath,
478
- sizeBytes: fileStat.size,
492
+ sizeBytes: fileStat.size + sidecarBytes,
479
493
  status: "healthy",
480
494
  meta: {
481
495
  updatedAt: fileStat.mtime.toISOString(),
@@ -483,6 +497,8 @@ async function fileBackedEntries(config, cacheKind, detectCorruption) {
483
497
  mapping: inferMapping(filePath, normalizedEntryId),
484
498
  scope: inferredScope,
485
499
  projectPath: inferProjectPath(filePath, config.pathRuntimeInfo),
500
+ // Health follows the cached artifact's own bytes: a zero-byte jar stays
501
+ // partial no matter how much its sidecar weighs.
486
502
  partial: fileStat.size === 0,
487
503
  corrupt: cacheKind === "registry" && detectCorruption ? await isCorruptRegistryJson(filePath) : false,
488
504
  inUse: filePath.endsWith(".lock") ||
@@ -495,6 +511,19 @@ async function fileBackedEntries(config, cacheKind, detectCorruption) {
495
511
  };
496
512
  });
497
513
  }
514
+ /**
515
+ * Bytes of the sidecar describing `downloadPath`, or 0 when there is none.
516
+ * A download cached before sidecars existed, or one whose sidecar write failed,
517
+ * is a normal state and must not fail the inventory.
518
+ */
519
+ async function downloadSidecarSizeBytes(downloadPath) {
520
+ try {
521
+ return (await stat(downloadSidecarPath(downloadPath))).size;
522
+ }
523
+ catch {
524
+ return 0;
525
+ }
526
+ }
498
527
  /**
499
528
  * Binary-remap cache entries are keyed by the final artifact id even when the
500
529
  * on-disk entry is a corrupt final directory or a leftover temp path.
@@ -661,6 +690,14 @@ export function createCacheRegistry(config) {
661
690
  workspaceCache.invalidate(entry.entryId);
662
691
  continue;
663
692
  }
693
+ if (entry.cacheKind === "downloads") {
694
+ // The sidecar is part of this entry, so it goes with the jar —
695
+ // outside the existsSync guard below, so a jar that vanished
696
+ // out-of-band since the listing still takes its sidecar with it
697
+ // instead of leaving an orphan behind. `force` makes a missing
698
+ // sidecar a no-op.
699
+ await rm(downloadSidecarPath(entry.path), { force: true });
700
+ }
664
701
  if (existsSync(entry.path)) {
665
702
  // Only binary-remap inventory can return directories as entries;
666
703
  // other file-backed kinds keep their existing file-only contract.
@@ -27,9 +27,9 @@ export declare const manageCacheShape: {
27
27
  jarPath: z.ZodOptional<z.ZodString>;
28
28
  entryId: z.ZodOptional<z.ZodString>;
29
29
  status: z.ZodOptional<z.ZodEnum<{
30
+ stale: "stale";
30
31
  healthy: "healthy";
31
32
  partial: "partial";
32
- stale: "stale";
33
33
  orphaned: "orphaned";
34
34
  corrupt: "corrupt";
35
35
  in_use: "in_use";
@@ -81,9 +81,9 @@ export declare const manageCacheSchema: z.ZodObject<{
81
81
  jarPath: z.ZodOptional<z.ZodString>;
82
82
  entryId: z.ZodOptional<z.ZodString>;
83
83
  status: z.ZodOptional<z.ZodEnum<{
84
+ stale: "stale";
84
85
  healthy: "healthy";
85
86
  partial: "partial";
86
- stale: "stale";
87
87
  orphaned: "orphaned";
88
88
  corrupt: "corrupt";
89
89
  in_use: "in_use";
@@ -21,14 +21,34 @@ export async function handleMixin(deps, input, detail, include) {
21
21
  details: {
22
22
  task: "mixin",
23
23
  failedStage: "input-validation",
24
- nextAction: "Pass version explicitly (e.g. \"1.21.10\"). task=\"project-summary\" supports preferProjectVersion for auto-detection from gradle.properties, but direct task=\"mixin\" requires an explicit version.",
24
+ nextAction: "Pass version explicitly: the Minecraft version this project targets. task=\"project-summary\" supports preferProjectVersion for auto-detection from gradle.properties, but direct task=\"mixin\" requires an explicit version. Call the suggested list-versions to see what is available, then replay the exampleCalls template with that version substituted.",
25
+ // No concrete version can be derived here -- the caller omitted it and this
26
+ // task does not auto-detect one. A `suggestedCall` is a payload the caller
27
+ // may replay verbatim, so filling the hole with a made-up version produced
28
+ // a call that RUNS and validates the mixin against the wrong Minecraft
29
+ // version, which is worse than no suggestion.
30
+ //
31
+ // So the two roles are split, following what the sibling "a version is
32
+ // required but none was resolved" site already does in
33
+ // src/source/class-source.ts: `suggestedCall` is a REAL next step that
34
+ // needs nothing the caller does not have (list-versions takes no
35
+ // arguments and answers exactly the question blocking them), while the
36
+ // task="mixin" retry shape travels as an `exampleCalls` template whose
37
+ // placeholder makes the substitution the caller must perform obvious.
38
+ ...buildSuggestedCall({ tool: "list-versions", params: {} }),
25
39
  ...buildSuggestedCall({
26
40
  tool: "validate-project",
27
- params: {
28
- task: "mixin",
29
- subject: input.subject,
30
- version: "1.21.10"
31
- }
41
+ params: undefined,
42
+ examples: [
43
+ {
44
+ params: {
45
+ task: "mixin",
46
+ subject: input.subject,
47
+ version: "<your-mc-version>"
48
+ },
49
+ reason: "Replace <your-mc-version> with the Minecraft version this project targets (gradle.properties, or task=\"project-summary\" with preferProjectVersion=true reports it)."
50
+ }
51
+ ]
32
52
  })
33
53
  }
34
54
  });
@@ -9,7 +9,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
9
9
  warnings: string[];
10
10
  tasks?: {
11
11
  "workspace.detected": {
12
- status: "error" | "ok" | "missing" | "skipped";
12
+ status: "ok" | "error" | "missing" | "skipped";
13
13
  durationMs?: number;
14
14
  error?: {
15
15
  code: string;
@@ -20,7 +20,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
20
20
  evidence?: string[];
21
21
  };
22
22
  "gradle.readable": {
23
- status: "error" | "ok" | "missing" | "skipped";
23
+ status: "ok" | "error" | "missing" | "skipped";
24
24
  durationMs?: number;
25
25
  error?: {
26
26
  code: string;
@@ -32,7 +32,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
32
32
  buildScripts?: string[];
33
33
  };
34
34
  "loom.cache.found": {
35
- status: "error" | "ok" | "missing" | "skipped";
35
+ status: "ok" | "error" | "missing" | "skipped";
36
36
  durationMs?: number;
37
37
  error?: {
38
38
  code: string;
@@ -43,7 +43,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
43
43
  cachePath?: string;
44
44
  };
45
45
  "minecraft.artifact.resolved": {
46
- status: "error" | "ok" | "missing" | "skipped";
46
+ status: "ok" | "error" | "missing" | "skipped";
47
47
  durationMs?: number;
48
48
  error?: {
49
49
  code: string;
@@ -55,7 +55,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
55
55
  mapping?: import("../../../types.js").SourceMapping;
56
56
  };
57
57
  "mixins.validated": {
58
- status: "error" | "ok" | "missing" | "skipped";
58
+ status: "ok" | "error" | "missing" | "skipped";
59
59
  durationMs?: number;
60
60
  error?: {
61
61
  code: string;
@@ -70,7 +70,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
70
70
  };
71
71
  };
72
72
  "accessWideners.validated": {
73
- status: "error" | "ok" | "missing" | "skipped";
73
+ status: "ok" | "error" | "missing" | "skipped";
74
74
  durationMs?: number;
75
75
  error?: {
76
76
  code: string;
@@ -84,7 +84,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
84
84
  };
85
85
  };
86
86
  "accessTransformers.validated": {
87
- status: "error" | "ok" | "missing" | "skipped";
87
+ status: "ok" | "error" | "missing" | "skipped";
88
88
  durationMs?: number;
89
89
  error?: {
90
90
  code: string;