@adhisang/minecraft-modding-mcp 7.0.0-rc.2 → 7.0.0-rc.3

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,6 +7,40 @@ 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.3] - 2026-09-07
11
+
12
+ ### Changed
13
+
14
+ - **A jar named by path, a Minecraft version, or a workspace now takes its `artifactId` from a sha256 of the identified jar's bytes**, not from the jar's path plus its modification time and size. 7.0.0-rc.1 and 7.0.0-rc.2 moved the Maven-coordinate routes onto content hashing; this release moves the rest. `target.kind="version"` and `target.kind="workspace"` resolve through the same jar route, so the rule covers Minecraft's own runtime jar as well as a jar you name, and the lightweight artifact probe behind `validate-project task="project-summary"` follows it too. A `touch`, a cache eviction followed by a re-fetch of byte-identical bytes, or a restore from backup therefore no longer mints a new id or forces a re-decompile. Three bounds. The jar's symlink-resolved path is still part of the id, so two byte-identical jars at two different real paths remain two artifacts. Within one server process a file's digest is reused while its size, modification time, inode and inode-change time all still match, so on a filesystem that does not advance the inode-change time on a write, an in-place replacement of the same length whose modification time was restored can be served the earlier digest until that memo entry is evicted. And one pre-existing limit is untouched: when a Loom split-source pair is indexed together, the artifact's signature comes from the primary sources jar only, so a change confined to the companion jar is not represented in the id.
15
+ - **Upgrade cost of the identity change above.** Every artifact reached through `target.kind="jar"`, `"version"` or `"workspace"` — the Minecraft runtime included — gets a new `artifactId` on its first resolve after upgrading and re-indexes there, with a fresh decompile wherever the decompile branch is reached. There is deliberately no migration shim. An `artifactId` you already hold keeps resolving out of the index and does not re-index. The decompiled and remapped outputs stored under the previous ids are not reachable from the new ones, and nothing reclaims them as part of the upgrade; they stay visible to `manage-cache` under the `decompiled-source` and `binary-remap` cache kinds. Nothing bounds that first call end to end — see the upgrade note under Documentation — so on a large jar it can surface as a client-side request timeout rather than as a slow success.
16
+ - **Wire contract change.** The `artifactSignature` field returned by the `mc://artifact/{artifactId}` resource is now a bare 64-character sha256 hex digest, or that digest followed by `:decompile`. For an artifact reached through a jar, version or workspace target it was previously `<mtimeMs>:<size>`, so a client matching that shape — anything keyed to a digits-colon-digits pattern — stops matching. The new shape applies to artifacts resolved after upgrading; an artifact recorded before the upgrade and reached by an `artifactId` you still hold returns the signature it was stored with, so a client that keeps old ids must accept both shapes. Artifacts reached through a Maven coordinate already published a bare digest and are unchanged. This is the only place the field reaches a client: no tool response carries it, and `resolve-artifact` and `manage-cache` never did.
17
+ - **Behaviour change — two stdio header shapes that used to frame now end the session.** A `Content-Length` header block followed by an extra empty line is malformed, because the declared length does not count that line. Previously two of the four such shapes — an LF-terminated header line closed by a CRLF empty line, followed by either another CRLF or another LF — happened to skip past the extra line and framed correctly. All four now read alike: the body window opens one or two bytes early, by the length of the extra line's own terminator, so the JSON does not parse, and a body-parse failure is framing-fatal, so the session ends and the frames behind it are lost. The other two shapes already failed this way. One case still slips through in either direction: the window is shifted rather than mis-sized, so a declared length that happens to count enough trailing whitespace absorbs the shift and the frame is delivered normally.
18
+
19
+ ### Fixed
20
+
21
+ - Cancelling a queued request that reuses a running `validate-project`'s JSON-RPC id no longer lets other requests run on the worker alongside that `validate-project`. A `notifications/cancelled` for such an id matched the queued entry and, on the id match alone, released the dispatch barrier the running `validate-project` was holding, so other work was admitted beside it. Cancellation now releases the barrier only for a queued request that raised it itself; 7.0.0-rc.2's rule that a request cannot release a barrier it does not hold now covers cancellation too. Nothing could be stranded by this: the barrier was wrongly released, never wrongly left standing.
22
+ - Two identity bugs on symlinked paths are fixed. The artifact probe behind `validate-project task="project-summary"` and `resolve-artifact` now agree on the `artifactId` of a runtime jar reached through a symlinked directory. The probe composed its id from the path as handed to it while `resolve-artifact` resolved the symlink first, so `tasks["minecraft.artifact.resolved"].artifactId` and `resolve-artifact` reported two different ids for one jar; both now resolve the path first, and the probe's id changes for such jars. Separately, when a jar named by path carries no Java sources and its `<basename>-sources.jar` sibling supplies them, and that sibling is itself a symlink, its id now follows the real path. The `sourceJarPath` the response publishes is unchanged.
23
+ - A stdio peer that ends its `Content-Length` header lines with a bare LF but closes the block with a CRLF empty line can now be read at all. That shape matched neither of the two terminator patterns the reader looked for, so the block was never recognized: the reader accumulated until it hit the 8 KiB header ceiling and then ended the session, losing that request and every request behind it. A header block now ends at its first empty line whichever way the lines are terminated, so all four combinations frame, and a `\r\n\r\n` sequence inside a JSON body — legal whitespace — can no longer be mistaken for the terminator. This removes that stall, not the framing-fatal class as a whole: two header shapes that used to frame by accident now fail, which is the behaviour change recorded under Changed.
24
+ - Several ways a request could end up with no reply, or with two, are closed in the stdio supervisor. A request rejected because the queue was full now lowers the `validate-project` dispatch barrier it had raised, instead of leaving it standing with nothing able to lower it and nothing behind it able to dispatch. A fault while handling a worker's response, a fault inside the admission recovery itself, and a dispatch that fails while the queue drains are each now answered with a JSON-RPC `-32603` on the request's own id — previously the first two escaped into the frame reader, which reported a parse error and dropped the frame, and the third stranded the request and parked everything behind it; because only `validate-project` arms a deadline, any other request lost that way waited on its id for the life of the session. A missing pending entry is no longer read as proof the client was answered: when a fault interrupts the supervisor between dropping its own record of the request and handing the reply to the client, the id is still answered; a plain failed write of a reply is still only logged. A rollback that itself fails no longer lets a second terminal reply go out for one id. An in-flight `initialize` whose answer is lost to a fault now restarts the handshake against a replacement worker, where before it could wait indefinitely. And an error object whose own `toString` throws can no longer slip past these guards.
25
+ - Two limits on the repairs above, stated because a user can reach them. When the supervisor's cleanup after such a fault cannot prove the request was removed, it reports and stands down rather than risk two replies for one id; only `validate-project` carries a deadline of its own, so any other request left in that state waits for the worker to exit. And the record that discards a worker's late answer for an already-settled id holds at most 1024 ids at a time — a pre-existing cap this release does not change — so beyond that the oldest is evicted and a very old worker's late answer for an evicted id would pass through.
26
+
27
+ ### Performance
28
+
29
+ - A repository's definitive rejection of a URL — 403, 404 or 410 — is now remembered, so resolving the same target again does not re-issue the requests every configured repository already refused. Previously nothing about a failed download was recorded: only bytes land in the download cache, so a coordinate whose sources jar does not exist paid the full repository sweep on every resolve, and a caller retrying after a timeout paid the identical sweep again. The bounds are part of the feature. The records live in memory only and are lost on restart. Each expires after about five minutes. At most 1024 records are kept at once; past that, expired records are dropped first and then the oldest, so a record can be lost before its five minutes are up. Only those three statuses are eligible: a 5xx, a 429, a 401, a request timeout and a network error are never remembered and are always retried. A `-SNAPSHOT` coordinate is never recorded — it is the one version shape this server treats as mutable — since its absence can be a publish in progress. And the records are kept per cache directory, so two configurations pointing at different cache directories never inherit each other's verdicts.
30
+
31
+ ### Documentation
32
+
33
+ - **Correction to `docs/tool-reference.md` as shipped in 7.0.0-rc.2.** That release corrected the "Inspecting a Fabric / loader dependency like vanilla" target-shape table's claim that dependency members always come back with `qualityFlags: ["dependency-mapping-unverified"]`, but left the identical claim standing in the prose above it. The prose now names both branches: for a non-obfuscated mapping request, a source-backed jar that can honor it returns `mappingApplied` equal to the request and `qualityFlags` including `"source-backed"`; every other dependency jar answering that same request returns `mappingApplied: "obfuscated"` and `qualityFlags` including `"dependency-mapping-unverified"`, with names taken verbatim from the jar's compiled names. A request that asks for `"obfuscated"` outright never reaches that branch: it passes through as `mappingApplied: "obfuscated"` with `"source-backed"` or `"decompiled"`, never with `"dependency-mapping-unverified"`.
34
+ - `docs/tool-reference.md`'s "which requests count as tool-chosen" list in the error-classification section named `get-class-members`, `batch-class-members`, and `inspect-minecraft`'s `class-members` task but omitted `verify-mixin-target`, which reads its target's members from bytecode under the same rule and carries the same per-site `issueOrigin` override. The list now names all four.
35
+ - **Correction to `docs/tool-reference.md` as shipped in 7.0.0-rc.2.** The environment-variable section documented byte-derived jar identity only for jars downloaded through `MCP_SOURCE_REPOS`. It now also records that jars served from `MCP_LOCAL_M2` and the Gradle dependency cache are identified from their bytes the same way whenever reached through a Maven coordinate. The section now also documents the byte-derived rule for jar-path, version and workspace targets, which this release introduces (see Changed).
36
+ - **Correction to `docs/tool-reference.md` as shipped in 7.0.0-rc.2.** The legacy-era exceptions bullet claimed the advertised `inputSchema` bytes are "identical to the pre-migration snapshots" without qualification. The wording now says what the guarantee covers: the bytes `tools/list` advertises for legacy-era tools come from frozen pre-migration contracts, and those contracts are deliberately extended when a tool gains a parameter in a later release — as `projectPath` was added to four tools in 7.0.0-rc.2 itself. The parity guarantee was never broken; only its description was imprecise about what it promises.
37
+ - **The 7.0.0-rc.2 section was corrected in place.** New practice: a false statement in a released section is now amended where readers find it, not merely annotated from a later section. Two claims in its Maven-coordinate hardening entry were wrong. First, "naming the offending segment in `details.component`, including when a mandatory segment is blank": no `details` object reaches a client, and the message names the segment only for a character-rule rejection — a blank segment or a wrong-shaped coordinate names none — so it now reads "whose message names the offending segment when a segment breaks the character rule"; its `dependency`-route `fieldErrors` half is untouched. Second, "no write location was ever reachable, since every cache and artifact write derives its filename from a sha256": that reason is false server-wide — the mapping caches name files from a version string — so only the reason changed, to "because nothing on these routes writes to a coordinate-derived path"; the conclusion stands — those version-derived paths are written only after a successful fetch from a fixed upstream, which a traversal-bearing version cannot reach. Both sentences carry a trailing `(Corrected 2026-09-05; see [Unreleased].)`; the record they point to was written under `[Unreleased]` and now lives in this entry. The published 7.0.0-rc.2 npm tarball ships the changelog as it was released and cannot be amended, so a reader of the copy installed from npm still sees the original wording.
38
+ - **The 7.0.0-rc.0 section was corrected in place.** Its "Ending the session no longer leaves a worker process behind" entry said the signal path and the supervisor-fault path both "exit non-zero once the worker group is collected or a bounded watchdog expires". That is false for the signal path: `SIGHUP` runs the ordinary cooperative shutdown and the process exits with code 0 once the detached worker group is collected, exactly as when stdin closes; the non-zero code belongs to the uncaught-supervisor-fault path, which is how a launcher tells a crash from a clean stop. That entry now states the two exit codes separately and carries a trailing `(Corrected 2026-09-05; see [Unreleased].)`. Correcting it also falsified the closing clause of 7.0.0-rc.2's "Correction to 7.0.0-rc.0." note — "the published 7.0.0-rc.0 entry is left as it stands" — so that clause now records the rc.0 entry as corrected in place and carries the same marker. The published 7.0.0-rc.0 and 7.0.0-rc.2 npm tarballs ship their changelogs as released and cannot be amended, so a reader of either installed copy still sees the original wording.
39
+ - **Upgrade note for 7.0.0-rc.2.** That release said each already-cached local dependency re-indexes once on its first resolve after upgrading, but only as a cost. It can be slow enough to matter: a consumer measured a 95.5-second first `get-class-members` call. Nothing bounds that first call end to end: individual stages carry their own budgets, but no deadline covers the call as a whole, and only `validate-project` has one. Stage progress is emitted by `validate-project` and `validate-mixin` alone, as the custom `$/stageUpdate` notification rather than `notifications/progress`, so it cannot reset a client's timeout even where it is emitted. Whether it returns or is cut off rests on the client's own request timeout, which this server neither sets nor observes. What a retry saves: the artifact index is SQLite and survives the timeout and a restart, so a retry addressed at the `artifactId` skips the re-index. A retry addressed at the same `target` skips the re-index too, and now also skips the repository sweep, but only within the bounds of the remembered rejections under Performance — in-process, about five minutes, definitive rejections only — and the sweep is paid again after a restart or once that window closes. The jar-identity change in this release adds one re-index, and where the decompile branch is reached a fresh decompile, to that same unbounded first call — for the Minecraft runtime as well as local dependencies. The 95.5-second measurement itself is unexplained.
40
+ - `docs/tool-reference.md` now describes artifact identity as it works. Its environment-variable section states the byte-derived rule for jar-path, version and workspace targets alongside the coordinate routes, names which file each jar-route branch hashes, and records both bounds: the symlink-resolved path is still part of the id, and a file's digest is memoized within a process against its size, modification time, inode and inode-change time. The `mc://artifact/{artifactId}` row states the shape of the `artifactSignature` it returns. The `validate-project` task table and `README.md` both record that the artifact probe reads the runtime jar's bytes to derive the `artifactId` it reports.
41
+ - `docs/tool-reference.md` now documents the remembered definitive rejections in its repository section, with the bounds on them: the eligible statuses, the roughly five-minute expiry, the `-SNAPSHOT` exclusion, the per-cache-directory keying, and the fact that the records are lost on restart.
42
+ - `docs/tool-reference.md`'s Framing section now says where a `Content-Length` header block ends, that all four terminator combinations delimit one, and what an extra empty line after the terminator costs — including that the resulting body-parse failure is framing-fatal, and that a declared length counting enough trailing whitespace can absorb the shift instead. Its synthetic-terminal-response section now names the supervisor's internal-fault `-32603` replies alongside the queue-overflow, worker-restart, timeout and startup-failure shapes, and records the one case in which the supervisor deliberately sends nothing at all.
43
+
10
44
  ## [7.0.0-rc.2] - 2026-09-04
11
45
 
12
46
  ### Added
@@ -34,7 +68,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
34
68
  - Cached artifact bytes that a concurrent resolve or cache prune deleted are now re-downloaded instead of failing the call with a raw `ENOENT`. Both a cache hit and a `304` revalidation treat vanished bytes as a cache miss and transfer again — unconditionally in the 304 case, carrying no validator from any source, caller-supplied `requestHeaders` included. A 304 revalidation no longer overwrites a fresher freshness record that a concurrent resolve left beside replaced `-SNAPSHOT` bytes: that record's digest, `etag` and `lastModified` are reported as they stand and no extra transfer is made. A cached entry that is present but unreadable — a permission error, a directory where a jar should be — is now reported instead of being treated as a miss, which had put that URL on the network on every call while hiding the actionable error. When that failed every repository, the final `ERR_REPO_FETCH_FAILED` names the offending cache path and its errno (`EACCES`, `EISDIR`, `EPERM`, `EIO`) in `hints` and says to remove or repair it.
35
69
  - Evicting a poisoned download no longer destroys a concurrent resolve's good jar, and an eviction that cannot delete no longer leaves an entry the cache re-adopts forever. A response that is not a readable archive is dropped from the download cache, but the deletion named only the path, so when another resolve of the same URL had meanwhile finished a real jar into the same slot, that jar was deleted on the strength of a check run against different bytes. The eviction now compares the digest of the body it is rejecting with the identity record beside the file and leaves the file alone when they differ; this narrows the window rather than closing it, since another process can still replace the file between the comparison and the deletion. An entry no record can identify is still deleted, so a cache poisoned by an earlier version still heals itself. Separately, when the deletion itself fails — a read-only cache directory, say — the file is now truncated to zero bytes, which the cache treats exactly like a missing one, so the next transfer replaces it; previously whatever survived was served back and rejected again on every later resolve.
36
70
  - `Retry-After` is now honoured in the RFC 9110 HTTP-date form. That form used to parse as `NaN` and fall through to a roughly 200-millisecond backoff, hammering a server that had just asked for a long pause. The numeric form is now read strictly, so `Retry-After: 12abc` is malformed rather than 12 seconds. Both forms stay capped at 30 seconds, a date already in the past behaves like `Retry-After: 0`, and only the space and horizontal tab RFC 9110 permits are stripped: a value padded with a newline, a form feed or a non-breaking space stays malformed instead of being repaired into a pause. The delta is measured against the local clock rather than the response's `Date` header.
37
- - A caller-supplied Maven coordinate can no longer reach a jar outside the configured local repository. Every segment of `group:artifact:version[:classifier]` becomes a path component, and none was validated: an `artifactId` or `version` carrying `../` walked out of the repository root, and a `groupId` of `.`, `..` or `.a` landed at the filesystem root. Each segment is now trimmed and checked against one rule — 1 to 200 characters from `[A-Za-z0-9._+-]`, no leading `.`, no `..` — with `version` and `classifier` additionally admitting a space, because `net.fabricmc:yarn:1.14 Pre-Release 1+build.10:v2` is a real coordinate. A rejected coordinate fails with `ERR_COORDINATE_PARSE_FAILED` naming the offending segment in `details.component`, including when a mandatory segment is blank; `g : a : 1.0` now resolves instead of failing on its padding. The `dependency` target route applies the same rule to `target.group` and `target.name` before it probes the Gradle cache (its old blocklist admitted `group="D:"` with `name="."`, which listed a directory outside the cache root into `candidatesSeen`), trims `target.version`, and names `target.group` or `target.name` in its field error instead of just `target`. Scope: the reachable impact was reading a file that is both named `<artifact>-<version>[-classifier][-sources].jar` and openable as a zip; no write location was ever reachable, since every cache and artifact write derives its filename from a sha256.
71
+ - A caller-supplied Maven coordinate can no longer reach a jar outside the configured local repository. Every segment of `group:artifact:version[:classifier]` becomes a path component, and none was validated: an `artifactId` or `version` carrying `../` walked out of the repository root, and a `groupId` of `.`, `..` or `.a` landed at the filesystem root. Each segment is now trimmed and checked against one rule — 1 to 200 characters from `[A-Za-z0-9._+-]`, no leading `.`, no `..` — with `version` and `classifier` additionally admitting a space, because `net.fabricmc:yarn:1.14 Pre-Release 1+build.10:v2` is a real coordinate. A rejected coordinate fails with `ERR_COORDINATE_PARSE_FAILED`, whose message names the offending segment when a segment breaks the character rule; `g : a : 1.0` now resolves instead of failing on its padding. The `dependency` target route applies the same rule to `target.group` and `target.name` before it probes the Gradle cache (its old blocklist admitted `group="D:"` with `name="."`, which listed a directory outside the cache root into `candidatesSeen`), trims `target.version`, and names `target.group` or `target.name` in its field error instead of just `target`. Scope: the reachable impact was reading a file that is both named `<artifact>-<version>[-classifier][-sources].jar` and openable as a zip; no write location was ever reachable, because nothing on these routes writes to a coordinate-derived path. (Corrected 2026-09-05; see [Unreleased].)
38
72
  - One corrupt `-sources.jar` in `~/.m2` no longer aborts artifact resolution. A truncated or non-zip sources jar among the resolution candidates used to surface as a generic `ERR_ARTIFACT_RESOLUTION_FAILED`, with the Gradle cache, the remote sources repositories, the local-binary decompile branch and the remote binary fallbacks never tried. An unopenable candidate is now treated as "no sources here" and resolution moves on, as the binary side already did. A jar the caller named directly still reports the open failure rather than laundering it into "no sources".
39
73
  - A sources jar found in `~/.m2` or the Gradle module cache now picks its binary companion by opening the candidates, and looks in both stores. Previously each store took the first companion that merely existed, so a truncated or half-copied jar in one cache shadowed a good copy in the other, and the unusable path was what got recorded on the artifact, so every later `get-class-members` on that `artifactId` went to the broken file. Candidates are now tried in preference order, the sources jar's own store first, and the first one that opens wins. A companion that opens but holds no `.class` entry carries the `binary-jar-no-classes` quality flag here too.
40
74
  - `validate-access-widener` and `validate-access-transformer` no longer answer `ERR_CONTEXT_UNRESOLVED` because of a directory name above the Gradle cache. A runtime jar's loader was read from its whole path, so a Fabric workspace checked out under a folder called `forge`, `moddev-notes` or `srg-test` was taken for a Forge or NeoForge runtime and refused. The loader is now read only from the part of the path a build tool wrote — everything below the deepest dot-directory (`.gradle`, `.m2`), Gradle's `caches` root or a project `build` directory, or the file name alone when the path enters none of them. A NeoForge-patched jar inside a Loom cache is still reported as NeoForge.
@@ -59,7 +93,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
59
93
 
60
94
  - `docs/tool-reference.md` no longer tells a caller to work around a parameter that now exists. Its "Essential Conventions" entry named `find-class` as the only flat-`artifactId` tool accepting a top-level `projectPath` and directed callers of the other four to resolve the artifact first, contradicting the "Common Pitfalls" entry in the same document. Both now state that all five tools accept a top-level `projectPath` for a target that cannot supply workspace context from its own fields; resolve-first stays documented in "Common Pitfalls" as the optional alternative it still is.
61
95
  - `docs/tool-reference.md` now documents the `context` block `get-class-members` and `batch-class-members` return. `jarSignature` is described as a signature over the jar's resolved path, modification time and size — good for spotting that the jar behind two responses is no longer the same file in the same state, usable neither as a content identity nor as a cache key. `minecraftVersion`, `mappingType`, `mappingNamespace` and `generatedAt` are described alongside it, including that `minecraftVersion` is `"unknown"` for a dependency artifact and for a jar inside a dependency store whose store-relative path does not name the Minecraft runtime, while a jar outside any such store is read from the first version-shaped token in its path.
62
- - **Correction to 7.0.0-rc.0.** That release's "Ending the session no longer leaves a worker process behind" entry says the signal path and the supervisor-fault path both "exit non-zero once the worker group is collected or a bounded watchdog expires". That is wrong about the signal path: `SIGHUP`, `SIGINT` and `SIGTERM` run the ordinary cooperative shutdown and the process exits with code 0 once the detached worker group is collected, exactly as when stdin closes. Only an uncaught supervisor fault (`supervisor.fatal`) and a client framing violation (`supervisor.client_framing_fatal`) set exit code 1 on a running session, which is what lets a launcher tell a crash from a clean stop. No code changed; the published 7.0.0-rc.0 entry is left as it stands.
96
+ - **Correction to 7.0.0-rc.0.** That release's "Ending the session no longer leaves a worker process behind" entry says the signal path and the supervisor-fault path both "exit non-zero once the worker group is collected or a bounded watchdog expires". That is wrong about the signal path: `SIGHUP`, `SIGINT` and `SIGTERM` run the ordinary cooperative shutdown and the process exits with code 0 once the detached worker group is collected, exactly as when stdin closes. Only an uncaught supervisor fault (`supervisor.fatal`) and a client framing violation (`supervisor.client_framing_fatal`) set exit code 1 on a running session, which is what lets a launcher tell a crash from a clean stop. No code changed, and the 7.0.0-rc.0 entry has since been corrected in place. (Corrected 2026-09-05; see [Unreleased].)
63
97
 
64
98
  ## [7.0.0-rc.1] - 2026-08-28
65
99
 
@@ -140,7 +174,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
140
174
  - 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.
141
175
  - 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.
142
176
  - 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.
143
- - 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.
177
+ - 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 and collect that group before the process leaves, and the exit code says which of the two happened: `SIGHUP` is an ordinary cooperative stop and exits with code 0, exactly as when stdin closes, while an uncaught supervisor fault reports a `supervisor.fatal` event and exits non-zero once the worker group is collected or a bounded watchdog expires — so a launcher can tell a crash from a clean stop. (Corrected 2026-09-05; see [Unreleased].)
144
178
  - 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.
145
179
  - 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.
146
180
 
package/README.md CHANGED
@@ -172,7 +172,7 @@ These notes cover high-frequency decisions during onboarding. For the full pitfa
172
172
  - `analyze-mod` and `validate-project` require structured `subject` objects and canonical `include` groups; stale string-subject or domain-include payloads return `ERR_INVALID_INPUT` with a retryable `suggestedCall`.
173
173
  - `validate-project task="project-summary"` propagates `preferProjectVersion=true` across discovered Mixin, Access Widener, and Access Transformer checks. If no version can be resolved from the request or `gradle.properties`, the summary returns recovery guidance instead of guessing.
174
174
  - `validate-mixin` and `validate-project` keep `mapping-health` lightweight for `obfuscated` and `mojang` validation, avoiding full Tiny mapping graph loads unless `intermediary` or `yarn` namespaces are requested.
175
- - `validate-project task="project-summary"` uses a lightweight artifact probe for `tasks["minecraft.artifact.resolved"]`; it does not decompile Minecraft or rebuild the source index just to report per-probe status. Set `VALIDATE_PROJECT_TASKS_OFF=1` to omit the additive `tasks` field.
175
+ - `validate-project task="project-summary"` uses a lightweight artifact probe for `tasks["minecraft.artifact.resolved"]`; it does not decompile Minecraft or rebuild the source index just to report per-probe status. It does read the runtime jar's bytes to derive the `artifactId` it reports, and repeat probes of the same jar in one process reuse that digest. Set `VALIDATE_PROJECT_TASKS_OFF=1` to omit the additive `tasks` field.
176
176
  - `validate-project` has a supervisor-owned end-to-end deadline of 120 seconds, including queue time. Set `MCP_VALIDATE_PROJECT_TIMEOUT_MS` to an ASCII-decimal value from `10000` through `600000` to override it. A timeout returns `ERR_TOOL_TIMEOUT`; a running timeout restarts the isolated worker before queued calls resume, while a queue timeout leaves the current worker untouched.
177
177
  - Artifact downloads are capped at 512 MiB (`536870912` bytes) each. Set `MCP_MAX_DOWNLOAD_BYTES` to an ASCII-decimal byte count to override it; values below 1 MiB are raised to 1 MiB, and anything else — non-numeric, malformed, or too large to be an exact integer — falls back to the default. The cap is checked twice: against `Content-Length` before any body is read (a header that is not a plain byte count is treated as absent rather than as a breach), and against the bytes actually received, so a chunked or mis-declared response is caught too. A download that exceeds it fails with `ERR_LIMIT_EXCEEDED` and is not retried; the partial file is deleted on a best-effort basis, so a file the server cannot remove may survive the refusal. A cached copy is never served in its place — a cap is a configuration verdict, not a passing outage, and standing a stale artifact in for it would hide the ceiling instead of reporting it. When every repository trips the cap, the terminal `ERR_REPO_FETCH_FAILED` reports it where a caller can actually read it: `hints` carries the sentence naming `MCP_MAX_DOWNLOAD_BYTES` and the byte count to clear, and `context.repoFailureCode` is `ERR_LIMIT_EXCEEDED`.
178
178
  - Nested (Jar-in-Jar) entries are capped at 64 MiB (`67108864` bytes) of uncompressed data each. Set `MCP_MAX_NESTED_JAR_ENTRY_BYTES` to override it, on the same rules as the download cap: ASCII decimal digits, values below 1 MiB raised to 1 MiB, anything else falling back to the default. The size is checked against the entry's declared uncompressed size before the read starts and again against the bytes actually streamed. A breach fails that one inner jar with `ERR_LIMIT_EXCEEDED`; the shell jar's other inner jars still resolve.
@@ -0,0 +1,130 @@
1
+ import type { MappingVariant } from "./types.js";
2
+ /**
3
+ * The single owner of artifact identity: what an artifactId is derived from,
4
+ * and how the derived signature is composed into the id.
5
+ *
6
+ * Five call sites produce a jar id - three in `src/source-resolver.ts` and two
7
+ * in `src/source/artifact-resolver.ts` - and every one of them reaches it
8
+ * through {@link jarArtifactIdentity}, so the derivation is written once. The
9
+ * coordinate cascade's five results are composed by {@link composeArtifactId},
10
+ * which is the only place either id layout appears.
11
+ *
12
+ * Both id spaces derive the same way: a sha256 of the bytes of the file the id
13
+ * is about, produced by {@link contentSignature}. Neither composer takes a bare
14
+ * `signature: string`, and the one signature type there is can only be built
15
+ * from a value tagged as a content digest, so nothing cheaper can reach the
16
+ * hash by a route nobody reviewed.
17
+ */
18
+ /** A sha256 of the bytes of the file itself. */
19
+ export interface ContentDigestSignature {
20
+ readonly from: "content-digest";
21
+ readonly value: string;
22
+ }
23
+ export declare function contentDigestSignature(value: string): ContentDigestSignature;
24
+ /**
25
+ * An id for a jar named by its path.
26
+ *
27
+ * The layout is `jar | <path> | <signature> | source`, plus `mojang-remapped`
28
+ * when that variant applies. The trailing `source` is a constant part of the
29
+ * layout with no producer that varies it; it is written out because the digest
30
+ * depends on every part.
31
+ */
32
+ export interface JarArtifactIdSpec {
33
+ readonly space: "jar";
34
+ /** Symlink-resolved, so two names for one file cannot mint two ids. */
35
+ readonly jarPath: string;
36
+ /**
37
+ * A sha256 of the jar's bytes, the same kind of signature the coordinate
38
+ * space takes. Required rather than merely permitted: a producer cannot
39
+ * identify a jar by something cheaper without changing this type.
40
+ */
41
+ readonly signature: ContentDigestSignature;
42
+ /**
43
+ * Appended to the signature after a `:` before hashing. The decompile route
44
+ * is the only user: the same jar read for its sources and the same jar handed
45
+ * to the decompiler are different artifacts and must not share an id.
46
+ */
47
+ readonly signatureQualifier?: string;
48
+ readonly mappingVariant?: MappingVariant;
49
+ }
50
+ /**
51
+ * An id for an artifact reached through a Maven coordinate.
52
+ *
53
+ * The layout is `coord | <coordinate> | <idSource> | <signature>`, plus
54
+ * `mojang-remapped` when that variant applies.
55
+ *
56
+ * The signature is a content digest of the bytes this cascade resolved, the
57
+ * same as the jar space's.
58
+ */
59
+ export interface CoordinateArtifactIdSpec {
60
+ readonly space: "coordinate";
61
+ readonly coordinate: string;
62
+ /**
63
+ * The id space this resolution belongs to, so artifacts discovered along
64
+ * different paths cannot collide in the hash.
65
+ */
66
+ readonly idSource: string;
67
+ readonly signature: ContentDigestSignature;
68
+ readonly mappingVariant?: MappingVariant;
69
+ }
70
+ export type ArtifactIdSpec = JarArtifactIdSpec | CoordinateArtifactIdSpec;
71
+ /**
72
+ * The qualifier the decompile route puts on a jar's signature. Shared so the
73
+ * full resolve and the lightweight probe cannot spell it differently and mint
74
+ * two ids for one decompiled jar.
75
+ */
76
+ export declare const DECOMPILE_SIGNATURE_QUALIFIER = "decompile";
77
+ /** Compose an artifactId. The only place either id layout is written down. */
78
+ export declare function composeArtifactId(spec: ArtifactIdSpec): string;
79
+ export interface JarArtifactIdentity {
80
+ /** The symlink-resolved path the signature was read from and the id composed from. */
81
+ readonly resolvedPath: string;
82
+ /** The signature as published in `ResolvedSourceArtifact.artifactSignature`, qualifier included. */
83
+ readonly signature: string;
84
+ readonly artifactId: string;
85
+ }
86
+ export interface JarArtifactIdentityOptions {
87
+ readonly signatureQualifier?: string;
88
+ readonly mappingVariant?: MappingVariant;
89
+ }
90
+ /**
91
+ * Normalize, derive, compose - in that order, for every jar-route producer.
92
+ *
93
+ * Normalizing first is what makes one jar under two names one artifact: the
94
+ * path that enters the hash is the resolved one, never the one handed in.
95
+ * `tests/source-service/probe-resolve-id-agreement.test.ts` pins that the
96
+ * lightweight probe and a full resolve agree through a symlinked directory.
97
+ *
98
+ * The derivation is {@link contentSignature}, so a jar named by path is
99
+ * identified by what is inside it: moving its mtime without changing a byte
100
+ * leaves the id where it was, and replacing its bytes moves the id.
101
+ *
102
+ * Hashing reads the whole file, so every producer calls this on its own branch,
103
+ * on the one file that branch's id is about, rather than once before the branch
104
+ * is picked. The branch that adopts a sibling `-sources.jar` therefore hashes
105
+ * that sibling and not the binary beside it, and the branch that finds no
106
+ * sources and cannot decompile hashes nothing at all.
107
+ */
108
+ export declare function jarArtifactIdentity(jarPath: string, options?: JarArtifactIdentityOptions): Promise<JarArtifactIdentity>;
109
+ /**
110
+ * The identity of a jar sitting on local disk: a sha256 of its bytes.
111
+ *
112
+ * This is the derivation behind every artifactId this module composes - the
113
+ * coordinate cascade's local legs call it directly, and the jar route reaches
114
+ * it through {@link jarArtifactIdentity}.
115
+ *
116
+ * `~/.m2` and the Gradle module cache move a file's mtime for reasons that have
117
+ * nothing to do with its contents - an eviction followed by a re-fetch of
118
+ * byte-identical bytes, a filesystem restore, a plain `touch`. An `mtimeMs:size`
119
+ * signature turns every one of those into a fresh artifactId and a fresh
120
+ * decompile, which is exactly the instability the download cache's
121
+ * content-addressed identity removed from the remote half of the coordinate
122
+ * cascade.
123
+ *
124
+ * Hashing is not free and that cascade is re-walked on every target-driven tool
125
+ * call, so the digest is memoized against the stat that produced it. The stat is
126
+ * taken *before* the digest on purpose: bytes replaced mid-hash are recorded
127
+ * against a stat they no longer have, so the entry is rejected on the next call
128
+ * and re-derived - a wasted hash, never a wrong identity.
129
+ */
130
+ export declare function contentSignature(jarPath: string): Promise<ContentDigestSignature>;
@@ -0,0 +1,142 @@
1
+ import { statSync } from "node:fs";
2
+ import { stableArtifactId } from "./config.js";
3
+ import { normalizeJarPath } from "./path-resolver.js";
4
+ import { digestFile } from "./repo-downloader.js";
5
+ export function contentDigestSignature(value) {
6
+ return { from: "content-digest", value };
7
+ }
8
+ /**
9
+ * The qualifier the decompile route puts on a jar's signature. Shared so the
10
+ * full resolve and the lightweight probe cannot spell it differently and mint
11
+ * two ids for one decompiled jar.
12
+ */
13
+ export const DECOMPILE_SIGNATURE_QUALIFIER = "decompile";
14
+ function qualifySignature(value, qualifier) {
15
+ return qualifier === undefined ? value : `${value}:${qualifier}`;
16
+ }
17
+ /** Compose an artifactId. The only place either id layout is written down. */
18
+ export function composeArtifactId(spec) {
19
+ const parts = spec.space === "jar"
20
+ // "jar" and "coord" are the tokens that enter the hash. They are not the
21
+ // `space` discriminant values, which only pick the layout.
22
+ ? ["jar", spec.jarPath, qualifySignature(spec.signature.value, spec.signatureQualifier), "source"]
23
+ : ["coord", spec.coordinate, spec.idSource, spec.signature.value];
24
+ if ((spec.mappingVariant ?? "pass") === "mojang-remapped") {
25
+ parts.push("mojang-remapped");
26
+ }
27
+ return stableArtifactId(parts);
28
+ }
29
+ /**
30
+ * Normalize, derive, compose - in that order, for every jar-route producer.
31
+ *
32
+ * Normalizing first is what makes one jar under two names one artifact: the
33
+ * path that enters the hash is the resolved one, never the one handed in.
34
+ * `tests/source-service/probe-resolve-id-agreement.test.ts` pins that the
35
+ * lightweight probe and a full resolve agree through a symlinked directory.
36
+ *
37
+ * The derivation is {@link contentSignature}, so a jar named by path is
38
+ * identified by what is inside it: moving its mtime without changing a byte
39
+ * leaves the id where it was, and replacing its bytes moves the id.
40
+ *
41
+ * Hashing reads the whole file, so every producer calls this on its own branch,
42
+ * on the one file that branch's id is about, rather than once before the branch
43
+ * is picked. The branch that adopts a sibling `-sources.jar` therefore hashes
44
+ * that sibling and not the binary beside it, and the branch that finds no
45
+ * sources and cannot decompile hashes nothing at all.
46
+ */
47
+ export async function jarArtifactIdentity(jarPath, options = {}) {
48
+ const resolvedPath = normalizeJarPath(jarPath);
49
+ const signature = await contentSignature(resolvedPath);
50
+ return {
51
+ resolvedPath,
52
+ signature: qualifySignature(signature.value, options.signatureQualifier),
53
+ artifactId: composeArtifactId({
54
+ space: "jar",
55
+ jarPath: resolvedPath,
56
+ signature,
57
+ signatureQualifier: options.signatureQualifier,
58
+ mappingVariant: options.mappingVariant
59
+ })
60
+ };
61
+ }
62
+ /**
63
+ * Digests already derived from a local jar, each pinned to the stat that
64
+ * produced it.
65
+ *
66
+ * Keyed by the symlink-resolved path, so a jar reached through two names is
67
+ * hashed once. The entry is only ever *reused*, never trusted on its own: it is
68
+ * discarded unless size, mtimeMs, inode and ctimeMs all still match the file on
69
+ * disk. ctimeMs is in that key because it is the field a write moves that
70
+ * `utimes` cannot put back, so a same-size replacement carrying a restored
71
+ * mtime is re-hashed rather than served the digest of the bytes it replaced.
72
+ *
73
+ * Known limitation: that guarantee is only as strong as the platform's
74
+ * inode-change timestamp. Where a filesystem does not advance ctimeMs on a
75
+ * write, this key degrades to size + mtime + inode and a same-size replacement
76
+ * whose mtime was restored is served the recorded digest until the entry is
77
+ * evicted. Nothing here re-reads the bytes to check.
78
+ *
79
+ * Bounded, like the helper caches in `src/source/artifact-resolver.ts`: this map
80
+ * is module-level and lives as long as the process, and a long-running server
81
+ * walks the resolution cascade once per target-driven tool call, so an
82
+ * unbounded map grows with every distinct jar path the server has ever seen.
83
+ * Eviction costs at most one re-hash, which is exactly what a cache miss
84
+ * already costs.
85
+ */
86
+ const contentSignatureCache = new Map();
87
+ const MAX_CONTENT_SIGNATURE_CACHE = 512;
88
+ /** Insert, dropping the oldest key first when the bound is reached. */
89
+ function rememberContentSignature(resolvedPath, entry) {
90
+ if (!contentSignatureCache.has(resolvedPath) && contentSignatureCache.size >= MAX_CONTENT_SIGNATURE_CACHE) {
91
+ const oldestKey = contentSignatureCache.keys().next().value;
92
+ if (oldestKey) {
93
+ contentSignatureCache.delete(oldestKey);
94
+ }
95
+ }
96
+ contentSignatureCache.set(resolvedPath, entry);
97
+ }
98
+ /**
99
+ * The identity of a jar sitting on local disk: a sha256 of its bytes.
100
+ *
101
+ * This is the derivation behind every artifactId this module composes - the
102
+ * coordinate cascade's local legs call it directly, and the jar route reaches
103
+ * it through {@link jarArtifactIdentity}.
104
+ *
105
+ * `~/.m2` and the Gradle module cache move a file's mtime for reasons that have
106
+ * nothing to do with its contents - an eviction followed by a re-fetch of
107
+ * byte-identical bytes, a filesystem restore, a plain `touch`. An `mtimeMs:size`
108
+ * signature turns every one of those into a fresh artifactId and a fresh
109
+ * decompile, which is exactly the instability the download cache's
110
+ * content-addressed identity removed from the remote half of the coordinate
111
+ * cascade.
112
+ *
113
+ * Hashing is not free and that cascade is re-walked on every target-driven tool
114
+ * call, so the digest is memoized against the stat that produced it. The stat is
115
+ * taken *before* the digest on purpose: bytes replaced mid-hash are recorded
116
+ * against a stat they no longer have, so the entry is rejected on the next call
117
+ * and re-derived - a wasted hash, never a wrong identity.
118
+ */
119
+ export async function contentSignature(jarPath) {
120
+ // The same normalization the jar route applies, kept so this path still
121
+ // refuses a vanished or non-jar file the way it always has.
122
+ const resolvedPath = normalizeJarPath(jarPath);
123
+ const stats = statSync(resolvedPath);
124
+ const cached = contentSignatureCache.get(resolvedPath);
125
+ if (cached &&
126
+ cached.mtimeMs === stats.mtimeMs &&
127
+ cached.size === stats.size &&
128
+ cached.ino === stats.ino &&
129
+ cached.ctimeMs === stats.ctimeMs) {
130
+ return contentDigestSignature(cached.sha256);
131
+ }
132
+ const { contentSha256 } = await digestFile(resolvedPath);
133
+ rememberContentSignature(resolvedPath, {
134
+ mtimeMs: stats.mtimeMs,
135
+ size: stats.size,
136
+ ino: stats.ino,
137
+ ctimeMs: stats.ctimeMs,
138
+ sha256: contentSha256
139
+ });
140
+ return contentDigestSignature(contentSha256);
141
+ }
142
+ //# sourceMappingURL=artifact-identity.js.map
package/dist/config.js CHANGED
@@ -229,8 +229,8 @@ export function buildArtifactAlias(input) {
229
229
  else {
230
230
  tokens.push("jar", aliasJarBase(input.value));
231
231
  }
232
- // mappingVariant is canonical — `artifactIdForJar` / `artifactIdForCoordinate`
233
- // bake "mojang-remapped" into the artifactId, so the same artifactId always
232
+ // mappingVariant is canonical — `composeArtifactId` in `src/artifact-identity.ts`
233
+ // bakes "mojang-remapped" into the artifactId, so the same artifactId always
234
234
  // produces the same alias regardless of how many times resolveArtifact is
235
235
  // called. The token here is purely a human-readable hint; the 12-char hash
236
236
  // suffix would already separate remapped artifacts from pass-through ones.
@@ -3,8 +3,8 @@ import { z } from "zod";
3
3
  export declare const analyzeModShape: {
4
4
  task: z.ZodEnum<{
5
5
  search: "search";
6
- summary: "summary";
7
6
  decompile: "decompile";
7
+ summary: "summary";
8
8
  "class-source": "class-source";
9
9
  members: "members";
10
10
  remap: "remap";
@@ -22,8 +22,8 @@ export declare const analyzeModShape: {
22
22
  class: "class";
23
23
  field: "field";
24
24
  method: "method";
25
- all: "all";
26
25
  content: "content";
26
+ all: "all";
27
27
  }>>;
28
28
  limit: z.ZodDefault<z.ZodNumber>;
29
29
  includeFiles: z.ZodDefault<z.ZodBoolean>;
@@ -51,8 +51,8 @@ export declare const analyzeModShape: {
51
51
  export declare const analyzeModSchema: z.ZodObject<{
52
52
  task: z.ZodEnum<{
53
53
  search: "search";
54
- summary: "summary";
55
54
  decompile: "decompile";
55
+ summary: "summary";
56
56
  "class-source": "class-source";
57
57
  members: "members";
58
58
  remap: "remap";
@@ -70,8 +70,8 @@ export declare const analyzeModSchema: z.ZodObject<{
70
70
  class: "class";
71
71
  field: "field";
72
72
  method: "method";
73
- all: "all";
74
73
  content: "content";
74
+ all: "all";
75
75
  }>>;
76
76
  limit: z.ZodDefault<z.ZodNumber>;
77
77
  includeFiles: z.ZodDefault<z.ZodBoolean>;
@@ -2,8 +2,8 @@ import { z } from "zod";
2
2
  import type { CheckSymbolExistsOutput, FindMappingOutput, GetClassApiMatrixOutput, ResolveMethodMappingExactOutput, ResolveWorkspaceSymbolOutput, TraceSymbolLifecycleOutput } from "../source-service.js";
3
3
  export declare const analyzeSymbolShape: {
4
4
  task: z.ZodEnum<{
5
- map: "map";
6
5
  workspace: "workspace";
6
+ map: "map";
7
7
  exists: "exists";
8
8
  "exact-map": "exact-map";
9
9
  lifecycle: "lifecycle";
@@ -72,8 +72,8 @@ export declare const analyzeSymbolShape: {
72
72
  };
73
73
  export declare const analyzeSymbolSchema: z.ZodObject<{
74
74
  task: z.ZodEnum<{
75
- map: "map";
76
75
  workspace: "workspace";
76
+ map: "map";
77
77
  exists: "exists";
78
78
  "exact-map": "exact-map";
79
79
  lifecycle: "lifecycle";
@@ -5,10 +5,10 @@ export declare const detailSchema: z.ZodEnum<{
5
5
  full: "full";
6
6
  }>;
7
7
  export declare const includeGroupSchema: z.ZodEnum<{
8
- entries: "entries";
9
8
  workspace: "workspace";
10
- candidates: "candidates";
9
+ entries: "entries";
11
10
  source: "source";
11
+ candidates: "candidates";
12
12
  warnings: "warnings";
13
13
  registry: "registry";
14
14
  preview: "preview";
@@ -16,23 +16,70 @@ const clearIdleTimerHandle = (handle) => {
16
16
  clearNodeTimeout(handle);
17
17
  };
18
18
  /**
19
- * The FIRST header terminator in the buffer, whichever style it is.
19
+ * The end of the header block: the FIRST EMPTY LINE in the buffer.
20
20
  *
21
- * Preferring a CRLFCRLF found anywhere over an earlier LFLF mis-framed every
22
- * LF-framed peer whose JSON body happened to contain a raw `\r\n\r\n` legal
23
- * inter-token whitespace because the header block was then cut at a boundary
24
- * inside the body, losing that frame and the next.
21
+ * One rule generates every terminator style instead of a list of literals to
22
+ * match. A header line ends at an LF, and a CR immediately before that LF
23
+ * belongs to the terminator rather than to the line; the block ends at the
24
+ * first line that holds nothing but its own terminator. `index` is the first
25
+ * byte of that terminating sequence — so `buffer.slice(0, index)` is exactly
26
+ * the header text — and `delimiterBytes` spans through the empty line's LF, so
27
+ * the body opens at `index + delimiterBytes`. The four shapes a peer can
28
+ * produce are consequences, not cases:
29
+ *
30
+ * "\r\n\r\n" → 4 bytes "\r\n\n" → 3 bytes
31
+ * "\n\r\n" → 3 bytes "\n\n" → 2 bytes
32
+ *
33
+ * Scanning forward from byte 0 is also what keeps a real terminator ahead of
34
+ * any byte sequence inside a body: preferring a CRLFCRLF found ANYWHERE over
35
+ * an earlier LFLF mis-framed every LF-framed peer whose JSON body happened to
36
+ * contain a raw `\r\n\r\n` — legal inter-token whitespace — because the header
37
+ * block was then cut at a boundary inside the body, losing that frame and the
38
+ * next. A single forward scan cannot reach the body before the block ends, so
39
+ * no body byte can outrank the terminator and no two readings compete.
40
+ *
41
+ * Consequence worth stating, because it is load-bearing for the caller: an
42
+ * EXTRA empty line after the terminator is body, not header. The body window
43
+ * then opens on that empty line and is shifted by the two or three bytes the
44
+ * peer did not count, so it no longer covers the same span as the JSON value.
45
+ * All four extra-blank-line shapes behave alike here, which is the point: the
46
+ * reading does not depend on which terminator style the peer chose.
47
+ *
48
+ * What that shift COSTS is a separate question, and the answer is not always
49
+ * "the frame". The usual outcome is a window running off the end of the JSON,
50
+ * `readContentLengthMessage`'s body-parse failure and a framing-fatal — see
51
+ * {@link JsonRpcFramingFatalError}. But the window is only shifted, not
52
+ * mis-sized, so trailing whitespace INSIDE the declared length can absorb the
53
+ * shift exactly: a length that counts two trailing spaces, against two
54
+ * uncounted leading bytes, lands the window on `"\r\n" + <json>`, which
55
+ * `JSON.parse` accepts. Such a frame is delivered normally. The reader does
56
+ * not detect the extra blank line; it only ever sees where the bytes fall.
25
57
  */
26
58
  function findHeaderBoundary(buffer) {
27
- const crlfBoundary = buffer.indexOf("\r\n\r\n");
28
- const lfBoundary = buffer.indexOf("\n\n");
29
- if (crlfBoundary !== -1 && (lfBoundary === -1 || crlfBoundary < lfBoundary)) {
30
- return { index: crlfBoundary, delimiterBytes: 4 };
31
- }
32
- if (lfBoundary !== -1) {
33
- return { index: lfBoundary, delimiterBytes: 2 };
59
+ let searchFrom = 0;
60
+ while (true) {
61
+ const lineEnd = buffer.indexOf(0x0a, searchFrom);
62
+ if (lineEnd === -1) {
63
+ return undefined;
64
+ }
65
+ // The next line starts immediately after that LF. It is the empty line
66
+ // when the only bytes it holds are its own terminator: an optional CR and
67
+ // then an LF. An out-of-range read is `undefined`, which matches neither
68
+ // byte, so a truncated tail simply keeps the scan waiting for more input.
69
+ let cursor = lineEnd + 1;
70
+ if (buffer[cursor] === 0x0d) {
71
+ cursor += 1;
72
+ }
73
+ if (buffer[cursor] === 0x0a) {
74
+ // A CR in front of the FIRST LF terminates the preceding header line, so
75
+ // it is part of the delimiter, not of the headers. Guarding on
76
+ // `lineEnd > 0` keeps the lookbehind inside the buffer when the block is
77
+ // terminated at byte 0.
78
+ const index = lineEnd > 0 && buffer[lineEnd - 1] === 0x0d ? lineEnd - 1 : lineEnd;
79
+ return { index, delimiterBytes: cursor + 1 - index };
80
+ }
81
+ searchFrom = lineEnd + 1;
34
82
  }
35
- return undefined;
36
83
  }
37
84
  function parseJsonRpcMessage(json) {
38
85
  return parseJSONRPCMessage(JSON.parse(json));
@@ -435,11 +482,23 @@ export class JsonRpcFrameReader {
435
482
  continue;
436
483
  }
437
484
  if (/^\s*content-length\s*:/i.test(line)) {
438
- // Always re-inject with CRLF: choosing the separator from the next
439
- // buffered byte raced byte-granular chunking (the peer's \r may not
440
- // have arrived yet, yielding a "\n\r\n" boundary findHeaderBoundary
441
- // cannot see). With CRLF both peer styles stay recognizable:
442
- // "...\r\n" + "\r\n…" "\r\n\r\n", "...\r\n" + "\n…" → "\n\n".
485
+ // `line` has already had its trailing \r stripped, so the terminator
486
+ // has to be written back. Always re-inject CRLF: choosing the
487
+ // separator from the next buffered byte raced byte-granular chunking
488
+ // (the peer's \r may not have arrived yet, so the boundary style would
489
+ // depend on read timing rather than on the peer), and this keeps the
490
+ // output independent of arrival.
491
+ //
492
+ // It does not cost the FRAMING: findHeaderBoundary sees an empty line
493
+ // next either way — "...\r\n" + "\r\n…" and "...\r\n" + "\n…" are both
494
+ // first-empty-line boundaries — so no boundary moves. It does cost one
495
+ // BYTE when the peer wrote a bare LF, and that byte is measured
496
+ // against MAX_CONTENT_LENGTH_HEADER_BYTES: an LF-framed header block of
497
+ // exactly 8192 bytes frames on its own, but re-injected here it
498
+ // measures 8193 and is rejected. Exactly one block size is affected —
499
+ // 8192, since anything larger was already over the ceiling — and only
500
+ // when it follows a line-delimited frame, which is why this is left as
501
+ // it stands rather than traded for the chunking race.
443
502
  this.buffer = Buffer.concat([Buffer.from(`${line}\r\n`, "utf8"), this.buffer]);
444
503
  this.mode = "content-length";
445
504
  return undefined;
@@ -1,7 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { createError, ERROR_CODES } from "./errors.js";
3
3
  import { loadConfig } from "./config.js";
4
- import { artifactSignatureFromFile, normalizeJarPath } from "./path-resolver.js";
4
+ import { normalizeJarPath, readJarStatStamp } from "./path-resolver.js";
5
5
  import { createJarEntryReader } from "./source-jar-reader.js";
6
6
  import { artifactIdNamesMinecraftRuntime } from "./source/artifact-resolver.js";
7
7
  import { matchesMemberPattern } from "./source/member-pattern.js";
@@ -988,7 +988,7 @@ export class MinecraftExplorerService {
988
988
  // With no derivable version the conservative "obfuscated" stands — which
989
989
  // is also the right default for a dependency jar's own bytecode namespace.
990
990
  mappingNamespace: minecraftVersion && isUnobfuscatedVersion(minecraftVersion) ? "mojang" : "obfuscated",
991
- jarSignature: artifactSignatureFromFile(jarPath).sourceArtifactId,
991
+ jarSignature: readJarStatStamp(jarPath).sourceArtifactId,
992
992
  generatedAt: new Date().toISOString()
993
993
  };
994
994
  }