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

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +2 -0
  3. package/dist/cache-registry.d.ts +10 -0
  4. package/dist/cache-registry.js +5 -1
  5. package/dist/entry-tools/batch-class-members-service.js +8 -0
  6. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +21 -0
  7. package/dist/entry-tools/inspect-minecraft/internal.d.ts +4 -0
  8. package/dist/error-mapping.d.ts +46 -4
  9. package/dist/error-mapping.js +80 -11
  10. package/dist/index.js +3 -1
  11. package/dist/json-rpc-framing.d.ts +53 -1
  12. package/dist/json-rpc-framing.js +190 -25
  13. package/dist/mapping/loaders/tiny-loom-selection.js +10 -3
  14. package/dist/mapping/parsers/tiny.js +4 -0
  15. package/dist/mapping-service.js +24 -3
  16. package/dist/maven-resolver.d.ts +43 -0
  17. package/dist/maven-resolver.js +75 -4
  18. package/dist/maven-token.d.ts +65 -0
  19. package/dist/maven-token.js +120 -0
  20. package/dist/mcp-helpers.js +7 -3
  21. package/dist/minecraft-explorer-service.d.ts +13 -1
  22. package/dist/minecraft-explorer-service.js +200 -7
  23. package/dist/repo-downloader.d.ts +103 -8
  24. package/dist/repo-downloader.js +577 -66
  25. package/dist/source/artifact-resolver.d.ts +85 -2
  26. package/dist/source/artifact-resolver.js +216 -23
  27. package/dist/source/class-source.d.ts +2 -2
  28. package/dist/source/class-source.js +77 -19
  29. package/dist/source/indexer.js +14 -0
  30. package/dist/source/nested-jars.d.ts +16 -1
  31. package/dist/source/nested-jars.js +66 -3
  32. package/dist/source/workspace-target.js +39 -20
  33. package/dist/source-jar-reader.d.ts +30 -2
  34. package/dist/source-jar-reader.js +39 -8
  35. package/dist/source-resolver.d.ts +48 -0
  36. package/dist/source-resolver.js +550 -63
  37. package/dist/source-service.d.ts +9 -0
  38. package/dist/stdio-supervisor.d.ts +75 -1
  39. package/dist/stdio-supervisor.js +240 -14
  40. package/dist/storage/artifacts-repo.d.ts +2 -0
  41. package/dist/storage/artifacts-repo.js +22 -0
  42. package/dist/storage/db.js +32 -10
  43. package/dist/tool-guidance.js +6 -3
  44. package/dist/tool-schemas.d.ts +8 -0
  45. package/dist/tool-schemas.js +4 -0
  46. package/dist/v1-parity-schemas.js +16 -0
  47. package/dist/version-diff-service.js +25 -4
  48. package/dist/version-service.js +23 -8
  49. package/dist/warning-details.js +3 -1
  50. package/dist/workspace-mapping-service.d.ts +8 -1
  51. package/dist/workspace-mapping-service.js +28 -19
  52. package/docs/tool-reference.md +29 -7
  53. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,60 @@ 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.2] - 2026-09-04
11
+
12
+ ### Added
13
+
14
+ - Artifact downloads are now capped at 512 MiB per transfer, configurable with `MCP_MAX_DOWNLOAD_BYTES`. The ceiling is checked against the declared `Content-Length` before the body is read and again against the bytes actually received, so a chunked or mis-declared response is caught too. A breach fails with `ERR_LIMIT_EXCEEDED`, is not retried, and its partial file is removed on a best-effort basis; a cached copy is never served in its place. When every repository trips the cap, the final `ERR_REPO_FETCH_FAILED` says so: `hints` tells you to raise `MCP_MAX_DOWNLOAD_BYTES`, and `context.repoFailureCode` is `"ERR_LIMIT_EXCEEDED"`. Previously only a 15-second timeout bounded a transfer, so a fast link serving a very large body could fill the disk. The cap is per transfer and is unrelated to total cache size.
15
+ - Nested-jar extraction is now capped at 64 MiB per inner jar, configurable with `MCP_MAX_NESTED_JAR_ENTRY_BYTES`. The ceiling is checked against the entry's declared uncompressed size before extraction and against the bytes actually streamed. A breach fails that one entry with `ERR_LIMIT_EXCEEDED`; the shell jar's other inner jars still resolve. Previously inner jars were read whole into memory with no size check, so a highly compressible entry could expand far beyond the archive carrying it.
16
+ - The `nestedJars` error field is now bounded in bytes as well as entry count, and says when it has been shortened. Entries longer than 256 bytes are dropped individually, the list stops at 8 KiB in total, and `nestedJarsTruncated: true` accompanies a shortened list wherever one is emitted. Previously a truncated inventory was indistinguishable from a complete one.
17
+ - `resolve-artifact` now reports the `binary-jar-no-classes` quality flag for a binary jar that opens cleanly but contains no `.class` entry. Such jars are legitimate — `net.fabricmc:yarn` and `intermediary` `v2` carry only `mappings/mappings.tiny`, and a resource-only Fabric mod carries only `fabric.mod.json` and assets — so the flag describes the artifact and never refuses it. It is set for `coordinate`, `jar` and `version` targets alike. Known limit: a class-free jar that is not a Jar-in-Jar shell still fails during indexing with `ERR_DECOMPILER_FAILED` before any response exists, so the flag does not reach a caller in exactly that case.
18
+
19
+ ### Changed
20
+
21
+ - **Wire contract change.** `context.jarHash` is renamed to `context.jarSignature` on `get-class-members` and `batch-class-members`. The value never was a hash of the jar's bytes: it is a signature over the jar's resolved path, modification time and size, useful for spotting that a jar changed between two responses but not as a content identity or cache key. The value and its derivation are unchanged. Callers reading `context.jarHash` must read `context.jarSignature` instead.
22
+ - **Wire contract change.** An artifact named only by an opaque handle no longer draws the blame for carrying no binary jar. `get-class-members`, `batch-class-members` and `inspect-minecraft`'s `class-members` task now apply one rule: a caller-named jar target (`target: { kind: "jar", ... }`) is the only shape that reports `issueOrigin: "code_issue"`; every other shape, an `artifactId` or `resolved-id` handle from an earlier resolve included, reports `"tool_issue"`. Previously `get-class-members` reported `"code_issue"` for `target: { kind: "artifact", artifactId }`, even though a handle tells its holder nothing about whether the artifact has a binary jar and `resolve-artifact` does not accept one. `retryClass` is unchanged and still `"input"` here. The error now points at `manage-cache action="inspect"` with `selector: { artifactId }` and `include: ["cacheEntries"]`, which reports the artifact's stored jar as `meta.binaryJarPath` inside `cacheEntries`. Clients that branch on `issueOrigin` should read it per response rather than assume a fixed value per error code.
23
+ - **A dependency's `artifactId` is now derived from the jar's bytes when the jar is served from `~/.m2` or the Gradle dependency cache**, not from its modification time and size. This applies to the routes that reach a jar through a Maven coordinate, `target.kind="dependency"` and `target.kind="coordinate"`; a jar named directly by path (`target.kind="jar"`) is unchanged and still takes its identity from that path with the file's modification time and size. 7.0.0-rc.1's content-derived `artifactId` covered only the remote-download path, so on the local path a `touch`, a cache eviction and re-fetch of identical bytes, or a filesystem restore still minted a new `artifactId`. Each already-cached local dependency gets a new `artifactId` and re-indexes once on its first resolve after upgrading, including a fresh decompile for binary-only dependencies.
24
+
25
+ ### Fixed
26
+
27
+ - `search-class-source`, `get-artifact-file`, `list-artifact-files` and `index-artifact` now accept a top-level `projectPath` parameter, matching `find-class`, and advertise it in their `tools/list` `inputSchema`. Previously a `target.kind="workspace"` or unversioned `target.kind="dependency"` call to these four tools was rejected with an error asking for `projectPath`, but their schemas had no such field, so the error's own advice could not be followed.
28
+ - A dependency resolve no longer warns that its requested mapping "is not enforced" when a source-backed jar actually supplied it. The warning used to fire whenever a non-obfuscated mapping was requested for a dependency, contradicting the same response's `mappingApplied` and `qualityFlags` when the mapping had genuinely succeeded. It now fires only when the binary remap was actually skipped, that is, when `qualityFlags` includes `dependency-mapping-unverified`.
29
+ - A dependency's own coordinate version is no longer read as a signal about Minecraft's obfuscation state. A version that parses as a modern Minecraft version — any `26.x` or later, as `org.jetbrains:annotations:26.0.2` does — made a `mapping: "mojang"` request on a binary-only dependency return `mappingApplied: "mojang"` with no quality flag and no warning, claiming a remap that was never performed. Such a dependency now reports `mappingApplied: "obfuscated"` with the `dependency-mapping-unverified` flag and warning, on `resolve-artifact`, `get-class-source` and `get-class-members` alike. The obfuscation check now applies only to targets that genuinely name a Minecraft version — a `target.kind="version"`, or a `coordinate` target naming the Minecraft runtime itself such as `net.minecraft:client:26.1`; a `target.kind="dependency"` request is never treated as one — so vanilla resolution is unaffected.
30
+ - `get-class-members` and `batch-class-members` now report `context.minecraftVersion: "unknown"` for a dependency artifact instead of a plausible-but-wrong value. The version used to be guessed from the jar's path, which for a jar served out of the Gradle cache yielded the cache-layout constant `2.1` and under `~/.m2` the dependency's own release number, and a fallback then substituted the dependency's coordinate version. The workspace's Minecraft version is not substituted either: `"unknown"` reports what the tool genuinely knows. Vanilla artifacts are unaffected.
31
+ - `get-class-members`, `batch-class-members`, `find-class` and `get-class-source` now recognize a dependency artifact however it was reached. Previously only a `target.kind="dependency"` request marked the artifact as a dependency; the same jar reached by its Maven coordinate or reused by its `artifactId` was treated as vanilla, so `context.minecraftVersion` again reported the Gradle cache-layout constant `2.1` or the dependency's own release number, and a class-not-found miss drew a false hint to retry with `mapping="mojang"`. A dependency named by a Maven coordinate is now detected on every route, and `net.minecraft` alone no longer marks an artifact as Minecraft: the artifact name is read too, so `net.minecraft:launchwrapper:1.12` is a dependency, as is a group that merely ends in `net.minecraft` or an artifact whose name merely contains `minecraft-client`. A jar named by bare path (`target.kind="jar"`) under the Gradle dependency cache or the local Maven repository, wherever `MCP_LOCAL_M2` puts it, reports `"unknown"` unless the part of the path below the store's root names the Minecraft runtime; directories above the store are never consulted. Minecraft's own jars are unaffected. Remaining limit: a third-party jar whose own file name starts with `minecraft-`, such as `minecraft-client-helper`, still reports its own release number when named by a bare jar path; reach it with a `dependency` or `coordinate` target instead.
32
+ - `batch-class-members` and `inspect-minecraft`'s `class-members` task no longer blame the caller when the artifact they themselves resolved from the request's target carries no binary jar. Both collapse the request to a single artifact before reading members, and the failure was reported as `issueOrigin: "code_issue"` — once per entry on a batch — pointing at an input that could not have chosen otherwise. It is now `"tool_issue"`, matching `get-class-members`. A caller who named a jar themselves (`target: { kind: "jar", ... }`) still gets `"code_issue"`. This closes the gap 7.0.0-rc.1 recorded as outstanding for `batch-class-members`.
33
+ - A binary jar that `resolve-artifact` reports is now also persisted when the resolve was served from the warm cache. Previously that path skipped the write, so a binary companion that appeared in `~/.m2` or the Gradle cache after the artifact was first recorded, or the mojang-remapped jar reconciled for a `binary-remap:obf->mojang` artifact, reached the `resolve-artifact` response but not the stored artifact, and `get-class-members` on the returned `artifactId` then failed with `ERR_CONTEXT_UNRESOLVED` saying the artifact has no binary jar. Affected artifacts repair themselves on their next resolve; no re-index or cache clear is needed. A resolve that reaches no jar never clears a previously stored one.
34
+ - 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
+ - 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
+ - `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.
38
+ - 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
+ - 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
+ - `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.
41
+ - A stdio peer that sends a `Content-Length` header and then stops sending the body no longer silences the connection. 7.0.0-rc.0 closed this only for a declared length above the frame limit; a smaller declared length followed by no body still parked the reader forever, with every later request stuck behind it. After 30 seconds with no further bytes the session now ends with a diagnostic naming the declared and received byte counts. The budget measures silence, not total transfer time: every arriving byte re-arms it, so a slow or very large body is never cut off.
42
+ - A header block whose lines contradict each other — a second `Content-Length` line even when the first value was unusable, or a malformed line alongside a good one — is now rejected outright and ends the session. Previously the reader stopped at the first bad line and consumed only the header block, so the body bytes an earlier declaration had claimed were re-read as separate messages and the peer effectively chose where the frame ended. A block that never declares a usable length is still reported as an ordinary parse error and recovered from.
43
+ - Peers that frame `Content-Length` headers with bare LF line endings are no longer mis-read when the JSON body contains a `\r\n\r\n` pair, which is legal JSON whitespace. A CRLF terminator anywhere in the buffer used to win over an earlier LF one, cutting the header block inside the body and losing that message and the next. The first terminator now wins, whichever style it is.
44
+ - A fault while handling an already-decoded frame no longer changes how later replies are framed. Such a throw used to reset the detected framing mode, so every later response was silently downgraded to line framing while the faulting request went unanswered. The fault is now reported through the ordinary parse-error channel with the framing state left as it was, and it can no longer tear the session down.
45
+ - A running `validate-project` no longer loses its timeout when a client reuses its JSON-RPC id. The deadline was matched by id alone, so a queued duplicate with the same id was timed out in its place while the real `validate-project` kept running with no deadline and still holding the dispatch barrier; whenever the worker never answered, everything queued behind it waited forever. A deadline now belongs to the exact request it was armed for, and a request that does not hold the barrier cannot release it.
46
+ - An internal failure while handing a request to the MCP worker no longer leaves that request half-registered. The supervisor answers such a fault with a JSON-RPC `-32603` "failed to admit the request" error, but the entry it had already installed stayed live: for `validate-project` the abandoned deadline later fired a second reply for the same id, and the dispatch barrier stayed raised until it did. The `-32603` is now the only reply for that id — the entry, its deadline and any barrier it held are released before the error is written — and queued work resumes immediately. An `initialize` that faults at this point keeps its own handshake recovery and is not rolled back here.
47
+ - A `tools/call` whose `arguments` are nested thousands of levels deep — a few KB on the wire — is now answered instead of vanishing. The argument redaction that runs at admission had no depth bound, so such a frame overflowed the stack and the request got no reply on an id the client then waited on forever. The walk now stops at 32 levels and reports anything deeper as `"<max-depth>"` in the diagnostic copies (`meta.timeout.redactedToolArgs`, `meta.restart.redactedToolArgs`, marked by `redactedToolArgsModified`); no real tool schema nests near that deep. Any request the supervisor cannot admit, for any reason, is now answered with JSON-RPC `-32603` rather than dropped.
48
+ - A worker or JVM that writes one very long stderr line with no newline no longer makes the server's memory grow until that worker is next restarted. The pending line is capped at 64 KiB: the part already held is passed through as one truncated line with a `supervisor.worker_stderr_line_truncated` warning, the rest of that line is discarded, and normal line handling resumes at the next newline, so worker startup is still detected.
49
+ - `compare-versions` no longer reports every class as both added and removed when only one of the two versions has usable Mojang mappings. Each side used to be lifted independently, so a pair where one side stayed obfuscated compared two different namespaces and returned `unchanged: 0` beside two full class lists. The diff now falls back to the jars' own class names on both sides, the `obfuscated` namespace the result already reported, and the warning announcing that fallback lists `version` alongside `packageFilter` in its `affectedFields`. One case the fallback cannot repair: when the two versions straddle Minecraft's move to unobfuscated names and the obfuscated side's mappings cannot be loaded, the jars' own names are still two different schemes, so `warnings` now says outright that the added and removed lists are not meaningful and `unchanged` will be near zero.
50
+ - A mapping file that leaves a namespace column empty on a class row no longer files that class's fields and methods under the previous class. The previous name was left standing for that namespace, so every member row that followed was indexed under the wrong owner and a lookup could be answered with a member the owner does not declare. Such a row now clears the namespace instead.
51
+ - `MCP_LOOM_TINY_MAX_INDEX_ENTRIES` now accepts only plain ASCII digits. A value such as `1e9`, `2_000_000`, `10M` or `1.9` used to be read from its leading digits as 1, 2, 10 or 1, small enough to truncate every real Loom mapping load; such a value now falls back to the heap-derived budget. A plain integer such as `250000` is still honoured, and may still be deliberately small.
52
+ - `find-mapping` and `resolve-method-mapping-exact` now return the mapping loader's own warnings in `warnings` whenever a mapping was loaded, `mapping_unavailable` included, as `get-class-api-matrix` and `check-symbol-exists` already did. The notice that a Loom mapping index stopped at its entry budget — the one signal that the answer may be incomplete — was being dropped on exactly the two tools most likely to hit it, and a `mapping_unavailable` answer replaced those warnings with its own single "no mapping path is available" sentence; both now appear together. On a version with no Mojang mappings these two tools now also surface the pre-existing `does not expose client mappings URL` notice.
53
+ - `resolve-method-mapping-exact` now says when a `not_found` was caused by an `owner` with no package. An unqualified owner cannot be projected into the target namespace and can never match, but the response explained the miss only as an inherited or relocated member. The message now names the owner and asks for the fully-qualified one.
54
+ - A version-manifest or version-detail fetch that fails at the transport layer — DNS, a refused connection, a TLS handshake — now fails with `ERR_REPO_FETCH_FAILED` instead of `ERR_INTERNAL`, so a network fault no longer reads as a server bug. The underlying message travels in `details.cause`; the timeout mapping is unchanged.
55
+ - `MCP_SQLITE_CACHE_KB` and `MCP_SQLITE_MMAP_SIZE` now also tune the artifact index that `manage-cache` opens. It used to open the same database on the built-in defaults — 8000 KiB page cache, 256 MiB memory map — whatever the two variables said.
56
+ - A failure while rebuilding a corrupted artifact index now fails with `ERR_DB_FAILURE` and closes the half-opened database handle, instead of escaping as a raw filesystem error from a read-only directory or a full disk. `details.reason` is `"rebuild_failed"` and the underlying message is carried in the error message.
57
+
58
+ ### Documentation
59
+
60
+ - `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
+ - `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.
63
+
10
64
  ## [7.0.0-rc.1] - 2026-08-28
11
65
 
12
66
  ### Added
package/README.md CHANGED
@@ -174,6 +174,8 @@ These notes cover high-frequency decisions during onboarding. For the full pitfa
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
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.
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
+ - 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
+ - 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.
177
179
  - Queued calls resume only after the replacement worker completes initialization replay. If replacement startup or replay fails, queued tool calls terminate with `ERR_WORKER_RESTART` instead of waiting indefinitely. If unresolved process-tree cleanup fills the supervisor's two live-generation slots, new requests fail with the existing restart envelope and unavailable notifications are warning-dropped until cleanup or reconnect. On POSIX, an already-gone process group counts as cleaned up rather than leaving a stale cleanup token.
178
180
  - If a workspace was built with `GRADLE_USER_HOME=/tmp/...` or another isolated Gradle home, pass that path as `gradleUserHome` so source, mapping, runtime, and project validation lookups use the same Loom cache instead of stale caches under the MCP process home.
179
181
  - `manage-cache` reports corrupt Mojang binary-remap cache directories under `cacheKinds: ["binary-remap"]` with `status: "corrupt"`, and can delete them by `selector.artifactId` in preview/apply workflows.
@@ -45,6 +45,16 @@ export declare function pathContainsVersion(path: string, version: string): bool
45
45
  export type CacheRegistryConfig = {
46
46
  cacheDir: string;
47
47
  sqlitePath: string;
48
+ /**
49
+ * SQLite page-cache and memory-map tuning for the artifact index, as
50
+ * MCP_SQLITE_CACHE_KB and MCP_SQLITE_MMAP_SIZE resolve them. Optional because
51
+ * a caller with no opinion should get the same defaults `applyPragmas` gives
52
+ * every other reader of the index - but a caller that HAS one is the whole
53
+ * point: without these the registry opened the index on the built-in defaults,
54
+ * so the two variables tuned every consumer except manage-cache.
55
+ */
56
+ sqliteCacheKb?: number;
57
+ sqliteMmapSize?: number;
48
58
  pathRuntimeInfo?: PathRuntimeInfo;
49
59
  workspaceContextCache?: WorkspaceContextCache;
50
60
  };
@@ -227,7 +227,11 @@ function openDb(config) {
227
227
  if (!existsSync(config.sqlitePath)) {
228
228
  return undefined;
229
229
  }
230
- return openDatabase(config).db;
230
+ return openDatabase({
231
+ sqlitePath: config.sqlitePath,
232
+ sqliteCacheKb: config.sqliteCacheKb,
233
+ sqliteMmapSize: config.sqliteMmapSize
234
+ }).db;
231
235
  }
232
236
  function candidatePathsForEntry(entry) {
233
237
  const paths = new Set();
@@ -48,6 +48,14 @@ export class BatchClassMembersService {
48
48
  }
49
49
  const raw = (await this.deps.getClassMembers({
50
50
  artifactId: sharedArtifact.artifactId,
51
+ // This artifactId is OURS, not the caller's: the shared target was
52
+ // resolved above and every entry is dispatched by the result. Without
53
+ // saying so, get-class-members reads the bare presence of an
54
+ // artifactId as the caller having named the artifact, and reports a
55
+ // missing binary jar as their mistake - once per entry - though
56
+ // `target` here cannot name an artifact at all. Only a jar the caller
57
+ // named themselves is genuinely their choice.
58
+ artifactSelectedBy: input.target.kind === "jar" ? "caller" : "tool",
51
59
  className: entry.className,
52
60
  access: entry.access,
53
61
  includeSynthetic: entry.includeSynthetic,
@@ -1,5 +1,17 @@
1
1
  import { buildEntryToolResult, buildEntryToolMeta, createNextAction, createSummarySubject, createTruncationMeta } from "../../response-contract.js";
2
2
  import { buildClassSubject, resolveClassArtifactReference, invalidTaskSubjectError } from "../internal.js";
3
+ function artifactSelectedByFor(ref) {
4
+ if (!ref) {
5
+ return "tool";
6
+ }
7
+ // A `resolved-id` reference is NOT a caller choice, despite being written by
8
+ // the caller. The id is an opaque handle some earlier resolve produced: it
9
+ // says nothing about whether the artifact carries a binary jar, and it cannot
10
+ // be re-resolved into one that does. Only `target: { kind: "jar", ... }`
11
+ // names a jar the caller actually picked and can pick differently, which is
12
+ // the same line `get-class-members` draws for its own `artifact` target.
13
+ return ref.type === "resolve-target" && ref.target.kind === "jar" ? "caller" : "tool";
14
+ }
3
15
  export async function handleClassMembers(deps, subject, detail, include, limit) {
4
16
  if (subject.kind !== "class" && !(subject.kind === "workspace" && subject.focus?.kind === "class")) {
5
17
  invalidTaskSubjectError("class-members", subject);
@@ -9,6 +21,15 @@ export async function handleClassMembers(deps, subject, detail, include, limit)
9
21
  const members = await deps.getClassMembers({
10
22
  className: classSubject.className,
11
23
  artifactId: artifact.artifactId || undefined,
24
+ // The artifactId above is one WE produced - resolveClassArtifactReference
25
+ // collapses every subject shape to one, including the workspace
26
+ // auto-resolution that happens with no artifact reference at all. Only the
27
+ // caller's own reference says whether they picked the artifact, and only a
28
+ // jar target does: it names an exact jar they can name differently.
29
+ // Anything else (a resolved-id handle, a version/coordinate target, or an
30
+ // omitted reference) leaves the artifact effectively ours, so a missing
31
+ // binary jar is not their input to fix.
32
+ artifactSelectedBy: artifactSelectedByFor(classSubject.artifact),
12
33
  mapping: classSubject.mapping,
13
34
  scope: classSubject.scope,
14
35
  projectPath: classSubject.projectPath,
@@ -424,6 +424,10 @@ export type InspectMinecraftDeps = {
424
424
  strictVersion?: boolean;
425
425
  maxMembers?: number;
426
426
  includeDescriptors?: boolean;
427
+ /** Who chose the artifact: see `GetClassMembersInput.artifactSelectedBy`.
428
+ * This tool always collapses its subject to an artifactId before calling,
429
+ * so without it every failure would be attributed to the caller. */
430
+ artifactSelectedBy?: "caller" | "tool";
427
431
  }) => Promise<GetClassMembersOutput>;
428
432
  searchClassSource: (input: {
429
433
  artifactId: string;
@@ -65,6 +65,15 @@ export type ProblemDetails = {
65
65
  * dedicated typed field: `context` is primitive-only and can never carry it.
66
66
  */
67
67
  nestedJars?: string[];
68
+ /**
69
+ * True when `nestedJars` is a SHORTENED view of the shell's inventory —
70
+ * entries were dropped by the count cap, the per-entry length cap, or the
71
+ * total-bytes cap. Without it a caller cannot tell a complete inventory from
72
+ * a trimmed one and may conclude a class is bundled nowhere. Additive and
73
+ * omitted entirely when the published list is complete, matching the
74
+ * `candidatesTruncated` precedent.
75
+ */
76
+ nestedJarsTruncated?: boolean;
68
77
  failedStage?: string;
69
78
  context?: Record<string, string | number | boolean>;
70
79
  };
@@ -75,14 +84,47 @@ export type ProblemDetails = {
75
84
  * whole rather than partially published.
76
85
  */
77
86
  export declare function extractDidYouMean(details: unknown): DidYouMeanCandidate[] | undefined;
87
+ /**
88
+ * The `nestedJars` pair as published: the inventory plus the additive flag that
89
+ * says whether it is complete. Both keys are omitted when there is nothing to
90
+ * publish, so the object spreads directly into a ProblemDetails.
91
+ */
92
+ export type NestedJarsField = {
93
+ nestedJars?: string[];
94
+ nestedJarsTruncated?: boolean;
95
+ };
78
96
  /**
79
97
  * Validates and extracts a `nestedJars` inventory from error details.
80
98
  * `buildClassSourceNotFoundError` records it whenever the lookup ran against a
81
99
  * shell jar, but `context` is primitive-only, so without this the inventory
82
- * never left the process. Malformed payloads are dropped whole rather than
83
- * partially published, matching {@link extractDidYouMean}. An empty inventory
84
- * is dropped too: the producing site omits the key entirely in that case, so an
85
- * empty array carries no information a caller could act on.
100
+ * never left the process.
101
+ *
102
+ * Two distinct dispositions, deliberately not conflated:
103
+ * - MALFORMED (a non-string or empty-string element, anywhere in the array):
104
+ * the whole array is dropped, matching {@link extractDidYouMean}. The scan
105
+ * deliberately continues past the caps so a malformed element beyond them is
106
+ * still found — the upstream array is a real jar's own entry list, bounded by
107
+ * the archive, so the full scan is not an exposure.
108
+ * - OVERSIZED (an entry longer than {@link MAX_NESTED_JAR_ENTRY_BYTES}, an
109
+ * entry past the count cap, or one that would push the list past the total
110
+ * byte budget): that ENTRY alone is excluded and `nestedJarsTruncated` is
111
+ * set. An implausible name is not evidence that the rest of the inventory is
112
+ * untrustworthy, so it must not drop the array.
113
+ *
114
+ * An empty inventory is dropped as before: the producing site omits the key
115
+ * entirely in that case, so an empty array carries no information a caller could
116
+ * act on. If the caps leave nothing publishable, both keys are omitted rather
117
+ * than publishing a truncation flag with no list beside it.
118
+ */
119
+ export declare function extractNestedJarsField(details: unknown): NestedJarsField;
120
+ /**
121
+ * Inventory-only view of {@link extractNestedJarsField}.
122
+ *
123
+ * NOT for an emission site. All three - the tool envelope, the batch entry, the
124
+ * error resource - publish the field pair, because dropping the flag makes a
125
+ * shortened inventory indistinguishable from a complete one. This remains for
126
+ * callers that want the list alone (tests pinning the validation and capping
127
+ * rules), and a new publisher should reach for the field-returning form.
86
128
  */
87
129
  export declare function extractNestedJars(details: unknown): string[] | undefined;
88
130
  /**
@@ -35,28 +35,90 @@ export function extractDidYouMean(details) {
35
35
  // A shell jar bundles a handful of inner jars, not hundreds; the cap only
36
36
  // bounds a pathological inventory, it is not an expected truncation point.
37
37
  const MAX_NESTED_JAR_ENTRIES = 64;
38
+ // Per-entry length ceiling, in UTF-8 bytes. Real entries are jar-relative paths
39
+ // like "META-INF/jars/fabric-screen-handler-api-v1-2.0.5.jar" (~52 bytes); the
40
+ // zip format itself allows a 65535-byte name, so an entry name is attacker-sized
41
+ // unless bounded here. 256 bytes is ~5x the longest realistic entry and still
42
+ // leaves any genuine name intact.
43
+ const MAX_NESTED_JAR_ENTRY_BYTES = 256;
44
+ // Total ceiling for the published list, in UTF-8 bytes. A full fabric-api-style
45
+ // inventory (64 entries at ~52 bytes) is ~3.3 KiB, so 8 KiB carries every real
46
+ // inventory whole while bounding the worst case this field can add to an error
47
+ // payload — which matters because nothing else in this project bounds an
48
+ // outbound response (MCP_MAX_FRAME_BYTES governs inbound decoding only).
49
+ const MAX_NESTED_JARS_TOTAL_BYTES = 8 * 1024;
38
50
  /**
39
51
  * Validates and extracts a `nestedJars` inventory from error details.
40
52
  * `buildClassSourceNotFoundError` records it whenever the lookup ran against a
41
53
  * shell jar, but `context` is primitive-only, so without this the inventory
42
- * never left the process. Malformed payloads are dropped whole rather than
43
- * partially published, matching {@link extractDidYouMean}. An empty inventory
44
- * is dropped too: the producing site omits the key entirely in that case, so an
45
- * empty array carries no information a caller could act on.
54
+ * never left the process.
55
+ *
56
+ * Two distinct dispositions, deliberately not conflated:
57
+ * - MALFORMED (a non-string or empty-string element, anywhere in the array):
58
+ * the whole array is dropped, matching {@link extractDidYouMean}. The scan
59
+ * deliberately continues past the caps so a malformed element beyond them is
60
+ * still found — the upstream array is a real jar's own entry list, bounded by
61
+ * the archive, so the full scan is not an exposure.
62
+ * - OVERSIZED (an entry longer than {@link MAX_NESTED_JAR_ENTRY_BYTES}, an
63
+ * entry past the count cap, or one that would push the list past the total
64
+ * byte budget): that ENTRY alone is excluded and `nestedJarsTruncated` is
65
+ * set. An implausible name is not evidence that the rest of the inventory is
66
+ * untrustworthy, so it must not drop the array.
67
+ *
68
+ * An empty inventory is dropped as before: the producing site omits the key
69
+ * entirely in that case, so an empty array carries no information a caller could
70
+ * act on. If the caps leave nothing publishable, both keys are omitted rather
71
+ * than publishing a truncation flag with no list beside it.
46
72
  */
47
- export function extractNestedJars(details) {
73
+ export function extractNestedJarsField(details) {
48
74
  const raw = details?.nestedJars;
49
75
  if (!Array.isArray(raw) || raw.length === 0) {
50
- return undefined;
76
+ return {};
51
77
  }
52
78
  const cleaned = [];
79
+ let truncated = false;
80
+ let totalBytes = 0;
81
+ let budgetExhausted = false;
53
82
  for (const entry of raw) {
54
83
  if (typeof entry !== "string" || !entry) {
55
- return undefined;
84
+ return {};
85
+ }
86
+ if (budgetExhausted || cleaned.length >= MAX_NESTED_JAR_ENTRIES) {
87
+ truncated = true;
88
+ continue;
89
+ }
90
+ const entryBytes = Buffer.byteLength(entry, "utf8");
91
+ if (entryBytes > MAX_NESTED_JAR_ENTRY_BYTES) {
92
+ truncated = true;
93
+ continue;
56
94
  }
95
+ if (totalBytes + entryBytes > MAX_NESTED_JARS_TOTAL_BYTES) {
96
+ // Keep the published list a prefix of the surviving entries: once the
97
+ // budget is spent, stop admitting rather than cherry-picking short names
98
+ // from the tail.
99
+ truncated = true;
100
+ budgetExhausted = true;
101
+ continue;
102
+ }
103
+ totalBytes += entryBytes;
57
104
  cleaned.push(entry);
58
105
  }
59
- return cleaned.slice(0, MAX_NESTED_JAR_ENTRIES);
106
+ if (cleaned.length === 0) {
107
+ return {};
108
+ }
109
+ return { nestedJars: cleaned, ...(truncated ? { nestedJarsTruncated: true } : {}) };
110
+ }
111
+ /**
112
+ * Inventory-only view of {@link extractNestedJarsField}.
113
+ *
114
+ * NOT for an emission site. All three - the tool envelope, the batch entry, the
115
+ * error resource - publish the field pair, because dropping the flag makes a
116
+ * shortened inventory indistinguishable from a complete one. This remains for
117
+ * callers that want the list alone (tests pinning the validation and capping
118
+ * rules), and a new publisher should reach for the field-returning form.
119
+ */
120
+ export function extractNestedJars(details) {
121
+ return extractNestedJarsField(details).nestedJars;
60
122
  }
61
123
  const ISSUE_ORIGIN_VALUES = new Set([
62
124
  "code_issue",
@@ -319,7 +381,14 @@ const CONTEXT_ALLOWLIST = new Set([
319
381
  "maxMembers",
320
382
  "candidateCount",
321
383
  "candidatesSeen",
322
- "ambiguous"
384
+ "ambiguous",
385
+ // Why a repository cascade gave up, as the failing leg's own error code (e.g.
386
+ // "ERR_LIMIT_EXCEEDED"). The terminal ERR_REPO_FETCH_FAILED says only that
387
+ // repositories were unstable, which is wrong for a configuration-driven
388
+ // refusal; the underlying code is the machine-readable half of that
389
+ // correction, beside the human-readable `nextAction` hint. A bare code string
390
+ // - no url, no path, no message - which is why it can travel here at all.
391
+ "repoFailureCode"
323
392
  ]);
324
393
  /**
325
394
  * Pick the allowlisted, primitive-valued fields out of an AppError's `details`
@@ -381,7 +450,7 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
381
450
  const fieldErrors = extractFieldErrors(caughtError.details);
382
451
  const context = extractAllowlistedContext(caughtError.details);
383
452
  const didYouMean = extractDidYouMean(caughtError.details);
384
- const nestedJars = extractNestedJars(caughtError.details);
453
+ const nestedJarsField = extractNestedJarsField(caughtError.details);
385
454
  return {
386
455
  type: `https://minecraft-modding-mcp.dev/problems/${caughtError.code.toLowerCase()}`,
387
456
  title: "Tool execution error",
@@ -394,7 +463,7 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
394
463
  ...(baseHints ? { hints: baseHints } : {}),
395
464
  ...(options?.suggestedCall ? { suggestedCall: options.suggestedCall } : {}),
396
465
  ...(didYouMean ? { didYouMean } : {}),
397
- ...(nestedJars ? { nestedJars } : {}),
466
+ ...nestedJarsField,
398
467
  ...(context ? { context } : {})
399
468
  };
400
469
  }
package/dist/index.js CHANGED
@@ -175,7 +175,9 @@ const validateProjectService = new ValidateProjectService({
175
175
  const manageCacheService = new ManageCacheService({
176
176
  registry: createCacheRegistry({
177
177
  cacheDir: config.cacheDir,
178
- sqlitePath: config.sqlitePath
178
+ sqlitePath: config.sqlitePath,
179
+ sqliteCacheKb: config.sqliteCacheKb,
180
+ sqliteMmapSize: config.sqliteMmapSize
179
181
  })
180
182
  });
181
183
  const verifyMixinTargetService = new VerifyMixinTargetService({
@@ -5,6 +5,14 @@ export type ParsedJsonRpcFrame = {
5
5
  message: JSONRPCMessage;
6
6
  mode: ConcreteFramingMode;
7
7
  };
8
+ /**
9
+ * The opaque handle a {@link JsonRpcFrameReader}'s idle-budget timer is
10
+ * identified by. Only the reader's own scheduler/clearer pair interprets it,
11
+ * so a test can substitute a plain object for a real timer.
12
+ */
13
+ export type FrameIdleTimerHandle = unknown;
14
+ export type FrameIdleTimerScheduler = (callback: () => void, delayMs: number) => FrameIdleTimerHandle;
15
+ export type FrameIdleTimerClearer = (handle: FrameIdleTimerHandle) => void;
8
16
  /**
9
17
  * A framing violation the reader cannot provably recover from.
10
18
  *
@@ -32,10 +40,15 @@ export type ParsedJsonRpcFrame = {
32
40
  * - an oversized Content-Length whose declared body has NOT fully arrived
33
41
  * (waiting on it is what let a single unanswerable header wedge the
34
42
  * transport for the process lifetime),
43
+ * - an UNDER-limit Content-Length whose declared body stops arriving for the
44
+ * incomplete-frame idle budget — the same wedge, below the size check,
35
45
  * - a Content-Length body that is not valid JSON — under-declaration,
36
46
  * over-declaration and an honestly-framed bad body are indistinguishable,
37
47
  * and the first two have already desynchronized the stream,
38
- * - duplicate Content-Length headers the body length is ambiguous,
48
+ * - a header block that declared a body length and then contradicted it
49
+ * (duplicate Content-Length headers, a non-numeric value alongside a
50
+ * numeric one, junk after a good declaration) — bytes the peer counted as
51
+ * body would otherwise be re-read as frames,
39
52
  * - a Content-Length header block that never terminates within the header
40
53
  * limit — there is no delimiter left to resynchronize on.
41
54
  */
@@ -54,14 +67,34 @@ export declare function loadMaxFrameBytes(value?: string | undefined): number;
54
67
  export declare function encodeJsonRpcMessage(message: JSONRPCMessage, mode: ConcreteFramingMode): Buffer;
55
68
  export declare class JsonRpcFrameReader {
56
69
  private readonly maxFrameBytes;
70
+ private readonly incompleteFrameIdleMs;
71
+ private readonly scheduleTimer;
72
+ private readonly clearTimer;
57
73
  private mode;
58
74
  private buffer;
59
75
  private pendingChunks;
60
76
  private pendingBytes;
61
77
  private awaitedFrameEnd;
78
+ private awaitedBodyStart;
79
+ private idleTimer;
62
80
  private fatal;
81
+ /**
82
+ * @param options.maxFrameBytes Largest accepted frame; defaults to
83
+ * {@link loadMaxFrameBytes}.
84
+ * @param options.incompleteFrameIdleMs How long a declared Content-Length
85
+ * body may stop arriving before the session is terminated. This is IDLE
86
+ * time, not total time: every arriving byte clears and re-arms it, so a
87
+ * legitimately slow or very large body is never cut off. Defaults to
88
+ * 30 000 ms; a non-positive or non-finite value disables the budget.
89
+ * @param options.timerScheduler Schedules the idle budget; defaults to an
90
+ * `unref()`'d `setTimeout`. Injectable so tests can drive it directly.
91
+ * @param options.timerClearer Cancels a handle from `timerScheduler`.
92
+ */
63
93
  constructor(options?: {
64
94
  maxFrameBytes?: number;
95
+ incompleteFrameIdleMs?: number;
96
+ timerScheduler?: FrameIdleTimerScheduler;
97
+ timerClearer?: FrameIdleTimerClearer;
65
98
  });
66
99
  get currentMode(): FramingMode;
67
100
  /**
@@ -76,6 +109,25 @@ export declare class JsonRpcFrameReader {
76
109
  onFrame: (frame: ParsedJsonRpcFrame) => void;
77
110
  onError: (error: Error) => void;
78
111
  }): void;
112
+ private drainChunk;
113
+ private clearIdleTimer;
114
+ /**
115
+ * Puts an incomplete declared body on an idle clock.
116
+ *
117
+ * A Content-Length UNDER the frame limit whose body never arrives was the
118
+ * one wedge the size check could not see: `readContentLengthMessage` armed
119
+ * `awaitedFrameEnd`, `canCompleteFrame` then refused to look at anything
120
+ * until that many bytes existed, and every later frame piled up behind a
121
+ * body that was never coming. The budget is idle time — the caller clears it
122
+ * on every arriving chunk and this re-arms it — so only a stream that has
123
+ * gone silent mid-body is terminated.
124
+ *
125
+ * `onError` belongs to a `processChunk` call, so the timer fires through the
126
+ * handlers of the MOST RECENT call rather than the one that armed it. Every
127
+ * caller in this repository passes a stable handler pair on every chunk, and
128
+ * a caller that does not still gets a live pair rather than a stale one.
129
+ */
130
+ private armIdleTimer;
79
131
  private canCompleteFrame;
80
132
  private rejectOversizedIncompleteInput;
81
133
  /**