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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [7.0.0] - 2026-09-13
11
+
12
+ ### Fixed
13
+
14
+ - Several more ways a fault during the stdio supervisor's own fault-recovery work could leave a request permanently unanswered, or answered twice, are closed. A `validate-project` request could be left marked as still running forever if an internal recovery step for an earlier fault on that same request itself failed, blocking every later `validate-project` call for the rest of the session; that is now cleared first. If a client re-sent `initialize` to a worker that was already ready and the supervisor then faulted while recovering from that duplicate handshake, other requests still in flight on the worker being replaced were not answered and could hang for the rest of the session; they are now answered before the worker is replaced. And a request taken from the internal queue whose own reply step then failed in a narrow way could be left with no reply at all, or, in a different case, answered a second, spurious time; exactly one reply is now produced either way. Each of these requires an internal fault that is not known to occur in current Node.js and has not been observed in production.
15
+ - Two remaining internal call sites that build an error's logged description now use the same safe string conversion already used elsewhere for this reason, so a thrown value whose own string conversion itself throws can no longer slip past them either. Cosmetic; there is no known way to trigger it.
16
+ - Four more ways a fault on the stdio connection to the worker process could leave a request unanswered are closed. A write error to the worker other than a broken pipe used to be logged and otherwise ignored, leaving the connection pointed at a worker nothing could send to again, so every request already sent to it — and every request sent afterward — hung for the rest of the session; that fault now retires the broken worker and starts a replacement, answering the stranded request along the way. If that same fault lands while a still-starting successor worker is replaying the client's `initialize` handshake, the client's `initialize` call now fails immediately instead of waiting silently across every later restart attempt. A successful reply to `initialize` could also be lost outright if writing it to the client failed at the same moment as the fallback log message the supervisor writes about that failure; both are now recovered from together. And once `initialize` completes, a client may legally reuse its request id for a later call; if the worker then exited before answering that reused id, it used to be mistaken for the already-finished `initialize` and silently dropped instead of receiving its own reply.
17
+ - A large request sent in line-delimited framing right after a `Content-Length`-framed one was wrongly rejected as an oversized header, because the 8 KiB header-size ceiling kept applying even once the reader had already moved on to line framing; such a request is now accepted up to the ordinary frame-size limit. Separately, when an oversized line arrived without its terminating newline yet, discarding it did not also consume the rest of that same line once the newline did arrive; the tail could then be re-read as if it were a fresh, legitimate request of its own. That tail is now discarded along with the rest of the line it belongs to.
18
+ - Deleting a downloaded-jar entry with `manage-cache` could destroy a different jar than the one that was listed, when another resolve finished writing a fresh copy to the same cache slot in between; the delete now verifies it is still removing the jar it originally listed. A delete that could not actually remove a file — for example because the cache directory is read-only — is no longer reported as successful; it now returns a warning and the entry is not counted as deleted. Checking a cached download's size no longer treats a permission error the same as a missing file; previously that silently triggered a fresh download instead of surfacing the actionable error.
19
+ - `find-class` now resolves the correct fully qualified name for a class nested two or more levels inside another class. It previously dropped every enclosing type past the first level, producing a name that does not exist among the compiled classes, so a follow-up call using that name — reading its members or source, for example — failed with a spurious not-found error. A class nested exactly one level deep was already correct and is unaffected.
20
+ - An unrecognized failure opening the artifact index database — for example the database being locked or the disk being full — is now reported as the documented database-failure error instead of escaping unclassified. Previously only a failure already recognized as an I/O problem, a schema mismatch, or corruption was reported that way.
21
+ - Loading a Yarn/Tiny mapping file from a Maven-hosted jar is now bounded the same way nested-jar and download extraction already are. A `.tiny` entry inside such a jar could previously be decompressed in full with no size limit, so a small, highly compressed entry could expand to multiple gigabytes and exhaust memory before mapping parsing ever started.
22
+
10
23
  ## [7.0.0-rc.3] - 2026-09-07
11
24
 
12
25
  ### Changed
@@ -42,6 +42,22 @@ type CacheEntryPage = {
42
42
  * while still allowing major.minor sweeps where "1.21" matches "1.21.4".
43
43
  */
44
44
  export declare function pathContainsVersion(path: string, version: string): boolean;
45
+ /**
46
+ * The identity `deleteEntries` captures for a downloads-cache jar at listing
47
+ * time, so a prune that only ever looked at the file once cannot destroy a
48
+ * concurrent resolve's freshly-written replacement at the same path.
49
+ *
50
+ * A missing, unreadable, or malformed sidecar answers with no identity to
51
+ * prove - {@link discardCachedDownload} already treats that as licence to
52
+ * evict unconditionally, the "cannot prove otherwise" rule this module's own
53
+ * sidecar-less entries already rely on elsewhere.
54
+ *
55
+ * Exported for direct testing, the same way {@link pathContainsVersion} is.
56
+ */
57
+ export declare function readDownloadEntryIdentity(jarPath: string): Promise<{
58
+ url: string;
59
+ contentSha256: string;
60
+ } | undefined>;
45
61
  export type CacheRegistryConfig = {
46
62
  cacheDir: string;
47
63
  sqlitePath: string;
@@ -4,7 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { mapWithConcurrencyLimit } from "./concurrency.js";
5
5
  import { createError, ERROR_CODES } from "./errors.js";
6
6
  import { normalizeOptionalPathForHost } from "./path-converter.js";
7
- import { downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
7
+ import { discardCachedDownload, downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
8
8
  import { openDatabase } from "./storage/db.js";
9
9
  import { getProcessWorkspaceContextCache } from "./workspace-context-cache.js";
10
10
  export const PUBLIC_CACHE_KINDS = [
@@ -528,6 +528,33 @@ async function downloadSidecarSizeBytes(downloadPath) {
528
528
  return 0;
529
529
  }
530
530
  }
531
+ /**
532
+ * The identity `deleteEntries` captures for a downloads-cache jar at listing
533
+ * time, so a prune that only ever looked at the file once cannot destroy a
534
+ * concurrent resolve's freshly-written replacement at the same path.
535
+ *
536
+ * A missing, unreadable, or malformed sidecar answers with no identity to
537
+ * prove - {@link discardCachedDownload} already treats that as licence to
538
+ * evict unconditionally, the "cannot prove otherwise" rule this module's own
539
+ * sidecar-less entries already rely on elsewhere.
540
+ *
541
+ * Exported for direct testing, the same way {@link pathContainsVersion} is.
542
+ */
543
+ export async function readDownloadEntryIdentity(jarPath) {
544
+ try {
545
+ const parsed = JSON.parse(await readFile(downloadSidecarPath(jarPath), "utf8"));
546
+ if (typeof parsed.url === "string" &&
547
+ typeof parsed.contentSha256 === "string" &&
548
+ parsed.contentSha256.length > 0) {
549
+ return { url: parsed.url, contentSha256: parsed.contentSha256 };
550
+ }
551
+ }
552
+ catch {
553
+ // Missing, unreadable, or malformed sidecar: nothing to identify these
554
+ // bytes with.
555
+ }
556
+ return undefined;
557
+ }
531
558
  /**
532
559
  * Binary-remap cache entries are keyed by the final artifact id even when the
533
560
  * on-disk entry is a corrupt final directory or a leftover temp path.
@@ -682,7 +709,19 @@ export function createCacheRegistry(config) {
682
709
  async deleteEntries(input) {
683
710
  const entries = await collectEntries(input.cacheKinds, input.selector);
684
711
  const selectedBytes = entries.reduce((total, entry) => total + entry.sizeBytes, 0);
712
+ const warnings = [];
713
+ const failedEntries = [];
685
714
  if (input.executionMode === "apply") {
715
+ // Captured now, against the listing this call just made, rather than
716
+ // re-read right before each unlink below: the identity has to describe
717
+ // what THIS call selected, not whatever a concurrent resolve may have
718
+ // already replaced it with by the time the loop below reaches it.
719
+ const downloadIdentities = new Map();
720
+ for (const entry of entries) {
721
+ if (entry.cacheKind === "downloads") {
722
+ downloadIdentities.set(entry.path, await readDownloadEntryIdentity(entry.path));
723
+ }
724
+ }
686
725
  const db = openDb(config);
687
726
  try {
688
727
  for (const entry of entries) {
@@ -695,12 +734,40 @@ export function createCacheRegistry(config) {
695
734
  continue;
696
735
  }
697
736
  if (entry.cacheKind === "downloads") {
698
- // The sidecar is part of this entry, so it goes with the jar —
699
- // outside the existsSync guard below, so a jar that vanished
700
- // out-of-band since the listing still takes its sidecar with it
701
- // instead of leaving an orphan behind. `force` makes a missing
702
- // sidecar a no-op.
703
- await rm(downloadSidecarPath(entry.path), { force: true });
737
+ // Route through the same sidecar-identity check every other
738
+ // eviction of this shared cache goes through, instead of an
739
+ // unconditional unlink that cannot tell a poisoned jar from a
740
+ // concurrent resolve's good one sitting at the same path. This
741
+ // also retires the sidecar, so it goes with the jar exactly as
742
+ // before - but only when the check above says the jar is still
743
+ // the one this call listed. See discardCachedDownload's doc
744
+ // comment in repo-downloader.ts for what the check does and does
745
+ // not close.
746
+ const expectedIdentity = downloadIdentities.get(entry.path);
747
+ discardCachedDownload(entry.path, expectedIdentity);
748
+ // discardCachedDownload swallows a genuine unlink failure
749
+ // (EACCES, EBUSY, a locked file, ...) - documented there as an
750
+ // accepted limitation for its own best-effort callers. That is
751
+ // wrong for this explicit, user-facing delete/prune request:
752
+ // verify the postcondition instead of trusting the call's
753
+ // silence. The jar surviving is only a LEGITIMATE decline when
754
+ // the identity recorded beside it now genuinely differs from
755
+ // what this call captured at listing time - i.e. a concurrent
756
+ // resolve's replacement is sitting there, exactly the case
757
+ // discardCachedDownload itself declines for. A jar that is
758
+ // still present with no such change (or no readable identity at
759
+ // all) means the removal itself failed.
760
+ if (existsSync(entry.path)) {
761
+ const currentIdentity = await readDownloadEntryIdentity(entry.path);
762
+ const declinedForConcurrentReplacement = expectedIdentity !== undefined &&
763
+ currentIdentity !== undefined &&
764
+ currentIdentity.contentSha256 !== expectedIdentity.contentSha256;
765
+ if (!declinedForConcurrentReplacement) {
766
+ warnings.push(`Could not delete cached download (it may be locked or read-only): ${entry.path}`);
767
+ failedEntries.push(entry);
768
+ }
769
+ }
770
+ continue;
704
771
  }
705
772
  if (existsSync(entry.path)) {
706
773
  // Only binary-remap inventory can return directories as entries;
@@ -713,10 +780,11 @@ export function createCacheRegistry(config) {
713
780
  db?.close();
714
781
  }
715
782
  }
783
+ const failedBytes = failedEntries.reduce((total, entry) => total + entry.sizeBytes, 0);
716
784
  return {
717
- deletedEntries: entries.length,
718
- deletedBytes: selectedBytes,
719
- warnings: []
785
+ deletedEntries: entries.length - failedEntries.length,
786
+ deletedBytes: selectedBytes - failedBytes,
787
+ warnings
720
788
  };
721
789
  },
722
790
  async pruneEntries(input) {
@@ -78,6 +78,15 @@ export declare class JsonRpcFrameReader {
78
78
  private awaitedBodyStart;
79
79
  private idleTimer;
80
80
  private fatal;
81
+ /**
82
+ * Set when an oversized, not-yet-terminated line/header-less run was just
83
+ * discarded with no newline in hand. The bytes that eventually complete
84
+ * that same logical line are not a delimiter the reader can trust as the
85
+ * start of a fresh frame, so every byte up to and including the next 0x0a —
86
+ * however many chunks it takes to arrive — is swallowed unread before
87
+ * normal parsing resumes. See `rejectOversizedIncompleteInput`.
88
+ */
89
+ private discardingOversizedLine;
81
90
  /**
82
91
  * @param options.maxFrameBytes Largest accepted frame; defaults to
83
92
  * {@link loadMaxFrameBytes}.
@@ -128,6 +137,17 @@ export declare class JsonRpcFrameReader {
128
137
  * a caller that does not still gets a live pair rather than a stale one.
129
138
  */
130
139
  private armIdleTimer;
140
+ /**
141
+ * Whether the buffer, despite `mode` still sticking at "content-length"
142
+ * from an earlier frame, actually opens a line-mode frame — the same probe
143
+ * `readContentLengthMessage` uses to detect the mid-stream switch back to
144
+ * line framing (a JSON object/array opener can never begin a Content-Length
145
+ * header block). Used to keep the header-size ceiling scoped to buffers
146
+ * still being accumulated as a header block, so it never judges a line
147
+ * frame's bytes as an oversized header just because the switch hasn't been
148
+ * recognized yet.
149
+ */
150
+ private looksLikeLineFrame;
131
151
  private canCompleteFrame;
132
152
  private rejectOversizedIncompleteInput;
133
153
  /**
@@ -40,8 +40,10 @@ const clearIdleTimerHandle = (handle) => {
40
40
  *
41
41
  * Consequence worth stating, because it is load-bearing for the caller: an
42
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.
43
+ * then opens on that empty line, shifted by however many bytes that line's own
44
+ * terminator took one for a bare LF, two for CRLF bytes the peer's
45
+ * declared length did not count, so the window no longer covers the same span
46
+ * as the JSON value.
45
47
  * All four extra-blank-line shapes behave alike here, which is the point: the
46
48
  * reading does not depend on which terminator style the peer chose.
47
49
  *
@@ -171,6 +173,15 @@ export class JsonRpcFrameReader {
171
173
  awaitedBodyStart = -1;
172
174
  idleTimer;
173
175
  fatal = false;
176
+ /**
177
+ * Set when an oversized, not-yet-terminated line/header-less run was just
178
+ * discarded with no newline in hand. The bytes that eventually complete
179
+ * that same logical line are not a delimiter the reader can trust as the
180
+ * start of a fresh frame, so every byte up to and including the next 0x0a —
181
+ * however many chunks it takes to arrive — is swallowed unread before
182
+ * normal parsing resumes. See `rejectOversizedIncompleteInput`.
183
+ */
184
+ discardingOversizedLine = false;
174
185
  /**
175
186
  * @param options.maxFrameBytes Largest accepted frame; defaults to
176
187
  * {@link loadMaxFrameBytes}.
@@ -206,6 +217,7 @@ export class JsonRpcFrameReader {
206
217
  this.awaitedFrameEnd = -1;
207
218
  this.awaitedBodyStart = -1;
208
219
  this.fatal = false;
220
+ this.discardingOversizedLine = false;
209
221
  }
210
222
  clear() {
211
223
  this.clearIdleTimer();
@@ -216,6 +228,7 @@ export class JsonRpcFrameReader {
216
228
  this.awaitedFrameEnd = -1;
217
229
  this.awaitedBodyStart = -1;
218
230
  this.fatal = false;
231
+ this.discardingOversizedLine = false;
219
232
  }
220
233
  processChunk(chunk, handlers) {
221
234
  if (chunk.length === 0 || this.fatal) {
@@ -242,6 +255,24 @@ export class JsonRpcFrameReader {
242
255
  this.pendingBytes = 0;
243
256
  while (true) {
244
257
  try {
258
+ if (this.discardingOversizedLine) {
259
+ // Swallow bytes up to and including the next newline WITHOUT
260
+ // interpreting them as a frame — they are the tail of the line just
261
+ // rejected as oversized, not a fresh start, even if they happen to
262
+ // look like a well-formed message on their own. Only once that
263
+ // terminator is found does this resynchronize on the byte position
264
+ // the peer itself delimited.
265
+ const newlineIndex = this.buffer.indexOf(0x0a);
266
+ if (newlineIndex === -1) {
267
+ // The discarded bytes carry no information, so there is nothing
268
+ // to hold onto while waiting for the terminator.
269
+ this.buffer = Buffer.alloc(0);
270
+ return;
271
+ }
272
+ this.buffer = this.buffer.subarray(newlineIndex + 1);
273
+ this.discardingOversizedLine = false;
274
+ continue;
275
+ }
245
276
  this.rejectOversizedIncompleteInput();
246
277
  if (this.mode === "unknown") {
247
278
  const detected = this.detectMode();
@@ -297,6 +328,7 @@ export class JsonRpcFrameReader {
297
328
  this.buffer = Buffer.alloc(0);
298
329
  this.pendingChunks = [];
299
330
  this.pendingBytes = 0;
331
+ this.discardingOversizedLine = false;
300
332
  handlers.onError(error);
301
333
  return;
302
334
  }
@@ -361,6 +393,28 @@ export class JsonRpcFrameReader {
361
393
  handle = this.scheduleTimer(expire, this.incompleteFrameIdleMs);
362
394
  this.idleTimer = handle;
363
395
  }
396
+ /**
397
+ * Whether the buffer, despite `mode` still sticking at "content-length"
398
+ * from an earlier frame, actually opens a line-mode frame — the same probe
399
+ * `readContentLengthMessage` uses to detect the mid-stream switch back to
400
+ * line framing (a JSON object/array opener can never begin a Content-Length
401
+ * header block). Used to keep the header-size ceiling scoped to buffers
402
+ * still being accumulated as a header block, so it never judges a line
403
+ * frame's bytes as an oversized header just because the switch hasn't been
404
+ * recognized yet.
405
+ */
406
+ looksLikeLineFrame() {
407
+ let probeIndex = 0;
408
+ while (probeIndex < this.buffer.length &&
409
+ (this.buffer[probeIndex] === 0x20 ||
410
+ this.buffer[probeIndex] === 0x09 ||
411
+ this.buffer[probeIndex] === 0x0d ||
412
+ this.buffer[probeIndex] === 0x0a)) {
413
+ probeIndex += 1;
414
+ }
415
+ return (probeIndex < this.buffer.length &&
416
+ (this.buffer[probeIndex] === 0x7b /* '{' */ || this.buffer[probeIndex] === 0x5b /* '[' */));
417
+ }
364
418
  canCompleteFrame(chunk) {
365
419
  const bufferedBytes = this.buffer.length + this.pendingBytes;
366
420
  if (this.mode === "content-length" && this.awaitedFrameEnd >= 0) {
@@ -373,8 +427,17 @@ export class JsonRpcFrameReader {
373
427
  return chunk.includes(0x0a) || bufferedBytes > this.maxFrameBytes;
374
428
  }
375
429
  rejectOversizedIncompleteInput() {
376
- const headerBoundary = this.mode === "content-length" ? findHeaderBoundary(this.buffer) : undefined;
377
- if (this.mode === "content-length" &&
430
+ // Sticky "content-length" mode only means a header block is being
431
+ // accumulated when the buffer doesn't already look like a line frame; a
432
+ // JSON object/array opener here is the same mid-stream switch
433
+ // `readContentLengthMessage` recognizes, just not yet reached. The header
434
+ // ceiling below must be scoped to actual header accumulation, or a large
435
+ // line-mode frame arriving right after a Content-Length frame gets judged
436
+ // as an oversized header before the switch is detected.
437
+ const isLineFrameAfterContentLength = this.mode === "content-length" && this.looksLikeLineFrame();
438
+ const inHeaderAccumulation = this.mode === "content-length" && !isLineFrameAfterContentLength;
439
+ const headerBoundary = inHeaderAccumulation ? findHeaderBoundary(this.buffer) : undefined;
440
+ if (inHeaderAccumulation &&
378
441
  !headerBoundary &&
379
442
  this.buffer.length > MAX_CONTENT_LENGTH_HEADER_BYTES) {
380
443
  // No header terminator anywhere in an over-limit header block: there is
@@ -387,15 +450,25 @@ export class JsonRpcFrameReader {
387
450
  if (this.buffer.length <= this.maxFrameBytes) {
388
451
  return;
389
452
  }
390
- if (this.mode === "content-length" && headerBoundary) {
453
+ if (inHeaderAccumulation && headerBoundary) {
391
454
  return;
392
455
  }
393
- if (this.mode !== "content-length" && this.buffer.includes(0x0a)) {
456
+ if (!inHeaderAccumulation && this.buffer.includes(0x0a)) {
394
457
  return;
395
458
  }
396
459
  const observedBytes = this.buffer.length;
397
- const description = this.mode === "line" ? "Line-delimited JSON-RPC frame" : "Headerless JSON-RPC input";
460
+ const description = this.mode === "line" || isLineFrameAfterContentLength
461
+ ? "Line-delimited JSON-RPC frame"
462
+ : "Headerless JSON-RPC input";
398
463
  this.buffer = Buffer.alloc(0);
464
+ if (!inHeaderAccumulation) {
465
+ // The oversized run has no newline anywhere in it yet (the check above
466
+ // would otherwise have returned): remember to swallow bytes through the
467
+ // eventual terminator — wherever it arrives — before resuming normal
468
+ // parsing, so the discarded line's own tail is never re-read as a fresh
469
+ // frame (see `discardingOversizedLine` in `drainChunk`).
470
+ this.discardingOversizedLine = true;
471
+ }
399
472
  throw new Error(`${description} is ${observedBytes} bytes, exceeding the configured frame limit of ` +
400
473
  `${this.maxFrameBytes} bytes.`);
401
474
  }
@@ -1,2 +1,11 @@
1
+ import type { DirectionIndex, PairKey } from "../internal-types.js";
1
2
  import type { MappingLoaderDeps, MappingLoaderResult } from "./types.js";
3
+ /**
4
+ * `maxEntryBytes` reuses the same ceiling as nested-jar extraction
5
+ * ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
6
+ * download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
7
+ * decompressed in full before parsing, so an entry with a small compressed
8
+ * size and a huge inflated size (zip-bomb style) must still be bounded here.
9
+ */
10
+ export declare function parseTinyFromJar(jarPath: string, maxEntryBytes?: number): Promise<Map<PairKey, DirectionIndex>>;
2
11
  export declare function loadTinyPairsFromMaven(deps: MappingLoaderDeps, version: string): Promise<MappingLoaderResult>;
@@ -1,4 +1,5 @@
1
1
  import { defaultDownloadPath, downloadToCache } from "../../repo-downloader.js";
2
+ import { loadMaxNestedJarEntryBytes } from "../../source/nested-jars.js";
2
3
  import { collectMatchedJarEntriesAsUtf8 } from "../../source-jar-reader.js";
3
4
  import { mergeDirectionIndexes } from "../parsers/symbol-records.js";
4
5
  import { parseTinyMappingsInto } from "../parsers/tiny.js";
@@ -27,8 +28,15 @@ async function fetchYarnCoordinates(fetchFn, repoBase, version) {
27
28
  return [version];
28
29
  }
29
30
  }
30
- async function parseTinyFromJar(jarPath) {
31
- const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true })).sort((left, right) => left.filePath.localeCompare(right.filePath));
31
+ /**
32
+ * `maxEntryBytes` reuses the same ceiling as nested-jar extraction
33
+ * ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
34
+ * download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
35
+ * decompressed in full before parsing, so an entry with a small compressed
36
+ * size and a huge inflated size (zip-bomb style) must still be bounded here.
37
+ */
38
+ export async function parseTinyFromJar(jarPath, maxEntryBytes = loadMaxNestedJarEntryBytes()) {
39
+ const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true, maxBytes: maxEntryBytes })).sort((left, right) => left.filePath.localeCompare(right.filePath));
32
40
  // Parsed straight into the shared accumulator: a parse-then-merge loop would
33
41
  // hold each entry's full index alongside the accumulated one. `ensurePairIndex`
34
42
  // + `addLookupEntries` union into what is already there, matching what
@@ -771,13 +771,24 @@ async function cachedBytesResult(url, destinationPath, sidecar, cacheStatus) {
771
771
  * a zip reader opens it - so it must satisfy neither an immutable hit nor a
772
772
  * stale-if-error fallback. Treating it as absent lets the next transfer replace
773
773
  * it instead of pinning it forever.
774
+ *
775
+ * That equivalence stops at "missing", the same line {@link describeFileIfPresent}
776
+ * draws: only {@link isMissingFileError} answers a cache miss. A present-but-
777
+ * unreadable entry (EACCES from a locked-down cache directory, EISDIR, EIO)
778
+ * propagates instead of collapsing to 0, because this is the first stat this
779
+ * module makes on the path - collapsing it here would let `resolveCachedDownload`
780
+ * read "no cached bytes" and fall through to a live transfer without ever
781
+ * reaching the read path that already reports this failure correctly.
774
782
  */
775
783
  function cachedByteCount(filePath) {
776
784
  try {
777
785
  return statSync(filePath).size;
778
786
  }
779
- catch {
780
- return 0;
787
+ catch (error) {
788
+ if (isMissingFileError(error)) {
789
+ return 0;
790
+ }
791
+ throw error;
781
792
  }
782
793
  }
783
794
  /**
@@ -9,6 +9,7 @@ import { remapAndCountMembers, sliceMembersWithLimit, projectMembersForWire, pro
9
9
  import { collectDidYouMeanCandidates } from "./did-you-mean.js";
10
10
  import { matchesMemberPattern } from "./member-pattern.js";
11
11
  import { findNestedJarClasses, resolveUniqueNestedJarForClass } from "./nested-jars.js";
12
+ import { extractSymbolsFromSource } from "../symbols/symbol-extractor.js";
12
13
  import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "../page-cursor.js";
13
14
  import { dedupeQualityFlags, inheritArtifactMapping, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
14
15
  import { isUnobfuscatedVersion } from "../version-service.js";
@@ -407,6 +408,50 @@ function projectDecompiledFallback(fallback, level) {
407
408
  // The class-like symbol kinds findClass returns. MUST stay in sync with the JS-side
408
409
  // isTypeSymbol checks below; pushed down to SQL so non-type rows are never fetched.
409
410
  const TYPE_SYMBOL_KINDS = ["class", "interface", "enum", "record"];
411
+ /**
412
+ * Reconstruct the full nesting chain between a file's top-level type and a
413
+ * type declared two or more levels deeper inside it. The extractor stores
414
+ * only ONE qualifiedName per FILE (the top-level type), so naively
415
+ * concatenating `<topLevelFQN>.<symbolName>` is only correct for exactly one
416
+ * level of nesting — it silently drops every intermediate enclosing type.
417
+ *
418
+ * Walks the file's own type declarations from the top-level type downward,
419
+ * using brace-range containment to find which declaration directly encloses
420
+ * `targetLine` at each level, collecting each intermediate simple name.
421
+ *
422
+ * Returns the intermediate simple names (outermost first, target excluded)
423
+ * or undefined when the chain cannot be reconstructed — e.g. the file's
424
+ * content is unavailable — in which case the caller falls back to the
425
+ * single-level formula.
426
+ */
427
+ function resolveNestedTypeChain(svc, artifactId, filePath, topSimpleName, targetLine) {
428
+ const fileRow = svc.filesRepo.getFileContent(artifactId, filePath);
429
+ if (!fileRow)
430
+ return undefined;
431
+ const lines = fileRow.content.split(/\r?\n/);
432
+ const fileSymbols = extractSymbolsFromSource(filePath, fileRow.content);
433
+ let currentBody = classSourceHelpers.computeBraceRange(lines, fileSymbols, topSimpleName);
434
+ if (!currentBody)
435
+ return undefined;
436
+ const chain = [];
437
+ // Bounded by the number of type symbols in the file; guards against ever
438
+ // looping on a malformed structure.
439
+ for (let step = 0; step <= fileSymbols.length; step += 1) {
440
+ const childRanges = classSourceHelpers.computeNestedTypeRanges(lines, fileSymbols, currentBody);
441
+ if (childRanges.some((range) => range.declarationLine === targetLine)) {
442
+ return chain;
443
+ }
444
+ const containingChild = childRanges.find((range) => range.declarationLine < targetLine && targetLine <= range.endLine);
445
+ if (!containingChild)
446
+ return undefined;
447
+ const childSymbol = fileSymbols.find((symbol) => symbol.line === containingChild.declarationLine);
448
+ if (!childSymbol)
449
+ return undefined;
450
+ chain.push(childSymbol.symbolName);
451
+ currentBody = containingChild;
452
+ }
453
+ return undefined;
454
+ }
410
455
  export function findClass(svc, input) {
411
456
  const className = input.className.trim();
412
457
  if (!className) {
@@ -500,8 +545,18 @@ export function findClass(svc, input) {
500
545
  const enclosingQualifiedName = row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
501
546
  const enclosingSimpleName = enclosingQualifiedName.split(".").at(-1) ?? enclosingQualifiedName;
502
547
  const nested = enclosingSimpleName !== row.symbolName;
548
+ let qualifiedName = nested ? `${enclosingQualifiedName}.${row.symbolName}` : enclosingQualifiedName;
549
+ if (nested) {
550
+ // The formula above assumes exactly one level of nesting. When the type
551
+ // is nested two or more levels deep, reconstruct the real chain so the
552
+ // intermediate enclosing type(s) are not silently dropped.
553
+ const intermediateChain = resolveNestedTypeChain(svc, artifactId, row.filePath, enclosingSimpleName, row.line);
554
+ if (intermediateChain && intermediateChain.length > 0) {
555
+ qualifiedName = `${enclosingQualifiedName}.${[...intermediateChain, row.symbolName].join(".")}`;
556
+ }
557
+ }
503
558
  candidates.push({
504
- qualifiedName: nested ? `${enclosingQualifiedName}.${row.symbolName}` : enclosingQualifiedName,
559
+ qualifiedName,
505
560
  filePath: row.filePath,
506
561
  line: row.line,
507
562
  symbolKind: row.symbolKind,
@@ -1928,8 +1928,10 @@ export class StdioSupervisor {
1928
1928
  message: detail
1929
1929
  });
1930
1930
  });
1931
+ const key = requestKey(entry.pending.id);
1932
+ let released = false;
1933
+ let attemptedRelease = false;
1931
1934
  this.runRecoveryStep("queue.dispatch_settle", () => {
1932
- const key = requestKey(entry.pending.id);
1933
1935
  // Only THIS instance may be settled here. A fault before forwardRequest
1934
1936
  // installed anything leaves the request re-queued by its own no-child
1935
1937
  // fallback (a later drain owns it) or already answered by the
@@ -1937,17 +1939,51 @@ export class StdioSupervisor {
1937
1939
  // different live request, which keeps its own guarantee.
1938
1940
  if (this.pendingRequests.get(key) !== entry.pending)
1939
1941
  return;
1940
- if (!this.releaseForwardedRequest(key, entry.pending))
1941
- return;
1942
- this.writeSyntheticReply(entry.pending, {
1943
- jsonrpc: "2.0",
1944
- id: entry.pending.id,
1945
- error: {
1946
- code: -32603,
1947
- message: `MCP supervisor failed to dispatch the queued request: ${detail}`
1948
- }
1949
- });
1942
+ attemptedRelease = true;
1943
+ released = this.releaseForwardedRequest(key, entry.pending);
1950
1944
  });
1945
+ if (attemptedRelease && !released) {
1946
+ // releaseForwardedRequest deletes the entry and records its tombstone
1947
+ // BEFORE it returns, so a throw part-way through (recordFinalityTombstone's
1948
+ // only throw site today is the debug-log call during tombstone
1949
+ // eviction) can still have taken the id away from its entry. `released`
1950
+ // cannot tell "declined" apart from "threw after mutating", so the id
1951
+ // is read back rather than assumed — mirrors
1952
+ // answerFaultedWorkerResponse's worker_message.release_verify.
1953
+ //
1954
+ // Gated on `attemptedRelease`, not merely `!released`: the identity
1955
+ // check above (`pendingRequests.get(key) !== entry.pending`) returns
1956
+ // early, WITHOUT calling releaseForwardedRequest, whenever this entry
1957
+ // was never installed into pendingRequests to begin with — which is
1958
+ // exactly what happens when forwardRequest's no-child fallback re-queues
1959
+ // this same entry (queue not full) or already answered it itself (queue
1960
+ // full) before throwing later in that same fallback (e.g. from
1961
+ // scheduleRestart). In either of those cases the id is either still
1962
+ // waiting for a real dispatch that will answer it for real later, or
1963
+ // already answered — and this verify step cannot distinguish "never
1964
+ // installed" from "installed, then removed by a throwing release" using
1965
+ // `pendingRequests.has(key)` alone. Running it anyway would record a
1966
+ // spurious tombstone and send a synthetic reply for a request that gets
1967
+ // (or already got) a real one, i.e. two replies for the same id.
1968
+ this.runRecoveryStep("queue.dispatch_settle_verify", () => {
1969
+ if (entry.pending.method === "initialize" || this.pendingRequests.has(key))
1970
+ return;
1971
+ this.recordFinalityTombstone(key, entry.pending.mode);
1972
+ released = true;
1973
+ });
1974
+ }
1975
+ if (released) {
1976
+ this.runRecoveryStep("queue.dispatch_reply", () => {
1977
+ this.writeSyntheticReply(entry.pending, {
1978
+ jsonrpc: "2.0",
1979
+ id: entry.pending.id,
1980
+ error: {
1981
+ code: -32603,
1982
+ message: `MCP supervisor failed to dispatch the queued request: ${detail}`
1983
+ }
1984
+ });
1985
+ });
1986
+ }
1951
1987
  return false;
1952
1988
  }
1953
1989
  }
@@ -1966,7 +2002,7 @@ export class StdioSupervisor {
1966
2002
  }
1967
2003
  catch (error) {
1968
2004
  log("error", "supervisor.worker_spawn_throw", {
1969
- message: error instanceof Error ? error.message : String(error)
2005
+ message: describeThrown(error)
1970
2006
  });
1971
2007
  this.handleStartupFailure(token, { code: null, signal: null });
1972
2008
  return;
@@ -2166,7 +2202,33 @@ export class StdioSupervisor {
2166
2202
  // cap allows. That successor arms a fresh startup watchdog and, on
2167
2203
  // ready, re-forwards the retained initialize (handleWorkerReady), so the
2168
2204
  // handshake gets a second chance instead of stalling.
2205
+ //
2206
+ // Replacing the generation does not by itself terminalize any OTHER
2207
+ // request forwarded to the old child: when its `exit` eventually fires,
2208
+ // `handleWorkerExit` finds `this.child` already pointing at the
2209
+ // successor and takes its early-return branch, skipping
2210
+ // failPendingRequestsOnWorkerExit entirely. So that call runs here
2211
+ // first, mirroring the precedent at handleWorkerProcessError, and BEFORE
2212
+ // recoverTimedOutWorker replaces the generation. It does not touch the
2213
+ // retained `initialize` itself: failPendingRequestsOnWorkerExit carves
2214
+ // out `this.initializeRequest`'s key, which is exactly the entry this
2215
+ // lifecycle is about to continue on the successor.
2216
+ //
2217
+ // Split into two independent recovery steps, deliberately: these are two
2218
+ // unrelated effects (terminalizing OTHER stranded requests, and replacing
2219
+ // the generation so the retained initialize gets a second chance), and
2220
+ // they must not share a fault boundary. Before failPendingRequestsOnWorkerExit
2221
+ // existed here, recoverTimedOutWorker was the only statement in this step
2222
+ // and ran unconditionally on any path that reached it. Running both in one
2223
+ // `runRecoveryStep` would let a throw inside failPendingRequestsOnWorkerExit
2224
+ // (e.g. its own timerClearer call faulting for some OTHER pending
2225
+ // request's deadline timer) silently swallow the call to
2226
+ // recoverTimedOutWorker that follows it in the same callback — silently
2227
+ // skipping the one guarantee this whole branch exists to provide.
2169
2228
  if (pending.method === "initialize") {
2229
+ this.runRecoveryStep("worker_message.initialize_recovery_fail_pending", () => {
2230
+ this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
2231
+ });
2170
2232
  this.runRecoveryStep("worker_message.initialize_recovery", () => {
2171
2233
  this.recoverTimedOutWorker();
2172
2234
  });
@@ -2219,6 +2281,37 @@ export class StdioSupervisor {
2219
2281
  return;
2220
2282
  }
2221
2283
  log("warn", "supervisor.worker_stdin_error", { message: error.message });
2284
+ // This event fires only on child.stdin, which exists only once the child
2285
+ // has actually been spawned, but that does NOT collapse to a single
2286
+ // "always post-ready" case: handleWorkerReady's legacy-era replay of a
2287
+ // retained `initialize` (era === "legacy" with `this.initializeRequest`
2288
+ // set) forwards it to a freshly spawned successor WITHOUT calling
2289
+ // adoptActiveChild first — adoptActiveChild only runs once that
2290
+ // initialize's response actually comes back, or on the no-replay early
2291
+ // return. So a live child can have `child.stdin` while `this.childReady`
2292
+ // is still false, mirroring the fork handleWorkerProcessError already
2293
+ // makes on `wasReady`. Treating that window as post-ready would run
2294
+ // failPendingRequestsOnWorkerExit, which deliberately carves the retained
2295
+ // initialize's pending entry OUT of what it fails (so a later id reuse of
2296
+ // the completed initialize is not wrongly caught) — leaving the client's
2297
+ // `initialize` answered by nothing and re-replayed against every
2298
+ // successor forever if the stdin fault persists. Before this recovery
2299
+ // existed at all, a broken stdin left `this.child` pointing at a worker
2300
+ // nothing could ever write to again: scheduleRestart's own `this.child`
2301
+ // guard made every later restart attempt a permanent no-op, and any
2302
+ // request already forwarded to this child had nothing left that would
2303
+ // ever answer it.
2304
+ const wasReady = this.childReady;
2305
+ this.invalidateCurrentChild(child);
2306
+ this.beginTreeTermination(child);
2307
+ if (!wasReady) {
2308
+ this.handleStartupFailure(this.attemptToken, { code: null, signal: null });
2309
+ }
2310
+ else {
2311
+ this.consecutiveImmediateStandDowns = 0;
2312
+ this.failPendingRequestsOnWorkerExit({ code: null, signal: null });
2313
+ this.scheduleRestart(true);
2314
+ }
2222
2315
  }
2223
2316
  /**
2224
2317
  * Reassembles the worker's stderr into lines (the ready marker may be split
@@ -2379,13 +2472,17 @@ export class StdioSupervisor {
2379
2472
  if (this.isInitializationResponse(message)) {
2380
2473
  const id = getTrackedRequestId(message);
2381
2474
  let initializeMode;
2475
+ let initializeKey;
2476
+ let initializePending;
2382
2477
  if (id !== undefined) {
2383
- const key = requestKey(id);
2384
- initializeMode = this.pendingRequests.get(key)?.mode;
2385
- this.pendingRequests.delete(key);
2478
+ initializeKey = requestKey(id);
2479
+ initializePending = this.pendingRequests.get(initializeKey);
2480
+ initializeMode = initializePending?.mode;
2386
2481
  }
2387
2482
  initializeMode ??= this.modeForMessage(this.initializeRequest);
2388
2483
  if ("error" in message) {
2484
+ if (initializeKey !== undefined)
2485
+ this.pendingRequests.delete(initializeKey);
2389
2486
  if (!this.replayingInitialization && id !== undefined) {
2390
2487
  this.writeSyntheticReply({ id, era: this.era, mode: initializeMode }, buildLegacyJsonRpcError(id));
2391
2488
  const retainedIndex = this.queuedNotifications.findIndex((entry) => isRequest(entry) && requestKey(entry.id) === requestKey(id));
@@ -2401,6 +2498,8 @@ export class StdioSupervisor {
2401
2498
  return;
2402
2499
  }
2403
2500
  if (this.replayingInitialization) {
2501
+ if (initializeKey !== undefined)
2502
+ this.pendingRequests.delete(initializeKey);
2404
2503
  this.replayingInitialization = false;
2405
2504
  if (this.initializedNotification) {
2406
2505
  this.writeToWorker(child, this.initializedNotification);
@@ -2409,9 +2508,22 @@ export class StdioSupervisor {
2409
2508
  this.flushQueue();
2410
2509
  return;
2411
2510
  }
2511
+ // Mark the entry as settling and remove it BEFORE writeToClient, so a
2512
+ // throw there (or in the diagnostic write reporting that throw) still
2513
+ // leaves `settlingWorkerResponse` for `answerUndeliveredWorkerResponse`
2514
+ // to answer this id from later — mirrors the non-initialize response
2515
+ // path below, which does the same for every other successful reply.
2516
+ // Without this, the entry was gone and no marker recorded it, so a
2517
+ // faulted client write here permanently lost the reply to `initialize`.
2518
+ if (initializeKey !== undefined && initializePending) {
2519
+ this.settlingWorkerResponse = { key: initializeKey, snapshot: initializePending };
2520
+ }
2521
+ if (initializeKey !== undefined)
2522
+ this.pendingRequests.delete(initializeKey);
2412
2523
  this.clientInitialized = true;
2413
2524
  this.adoptActiveChild();
2414
2525
  this.writeToClient(message, initializeMode);
2526
+ this.settlingWorkerResponse = undefined;
2415
2527
  this.flushQueue();
2416
2528
  return;
2417
2529
  }
@@ -2446,13 +2558,19 @@ export class StdioSupervisor {
2446
2558
  // branch above, never here.
2447
2559
  this.settlingWorkerResponse = { key, snapshot: pending };
2448
2560
  }
2449
- if (pending?.deadlineTimer)
2450
- this.timerClearer(pending.deadlineTimer);
2561
+ // These two clears run BEFORE timerClearer: a validate-project id must
2562
+ // not stay "running" if the timer clear below throws. answerUndelivered-
2563
+ // WorkerResponse (which later answers this id via settlingWorkerResponse
2564
+ // on a writeToClient/drainQueue fault) never touches these two fields,
2565
+ // so leaving them ordered after a throwing call would strand the
2566
+ // barrier and every validate-project admitted behind it, permanently.
2451
2567
  if (pending?.toolName === "validate-project") {
2452
2568
  this.runningValidateKey = undefined;
2453
2569
  if (this.validateBarrierKey === key)
2454
2570
  this.validateBarrierKey = undefined;
2455
2571
  }
2572
+ if (pending?.deadlineTimer)
2573
+ this.timerClearer(pending.deadlineTimer);
2456
2574
  }
2457
2575
  }
2458
2576
  this.writeToClient(message, responseMode);
@@ -2558,7 +2676,12 @@ export class StdioSupervisor {
2558
2676
  // count to N and falsely escalate retryRecommendation to "report-bug".
2559
2677
  const pendingToolNames = [];
2560
2678
  for (const [key, pending] of this.pendingRequests.entries()) {
2561
- if (key === preservedInitializeKey)
2679
+ // Identity, not just id: a client may legally reuse `initialize`'s id
2680
+ // for a later request once initialize has completed. `initializeRequest`
2681
+ // (and so `preservedInitializeKey`) is retained past that point, so a
2682
+ // bare key match would treat the REUSED entry as the still-pending
2683
+ // initialize and skip it here — leaving it answered by nothing.
2684
+ if (key === preservedInitializeKey && pending.method === "initialize")
2562
2685
  continue;
2563
2686
  pendingToolNames.push(pending.toolName);
2564
2687
  }
@@ -2566,23 +2689,59 @@ export class StdioSupervisor {
2566
2689
  for (const [toolName, updated] of updatedByTool) {
2567
2690
  this.recentRestarts.set(toolName, updated);
2568
2691
  }
2692
+ // Each entry's cleanup and its reply run as two SEPARATE recovery steps,
2693
+ // not one bundled try around the whole loop body. Fault containment must
2694
+ // be per-entry, not just per-function: a throw from `this.timerClearer`
2695
+ // (or anything else in the cleanup half) for entry N must not abort the
2696
+ // `for` loop, or every entry after N in Map iteration order is left
2697
+ // completely unprocessed — not answered, and not cleared from
2698
+ // runningValidateKey/validateBarrierKey either. Since the old worker's
2699
+ // own `exit`/`error` handling short-circuits once `this.child` already
2700
+ // points at a successor generation, a request stranded that way here is
2701
+ // stranded permanently, and a stranded validate-project holding the
2702
+ // barrier keys would jam every later validate-project request behind it
2703
+ // forever. Splitting into two steps also means a fault in ONE entry's
2704
+ // cleanup cannot suppress that SAME entry's own reply.
2569
2705
  for (const [key, pending] of [...this.pendingRequests.entries()]) {
2570
- if (key === preservedInitializeKey)
2706
+ // See the identical guard above: a bare key match would also wrongly
2707
+ // skip a request that legally reused the completed initialize's id.
2708
+ if (key === preservedInitializeKey && pending.method === "initialize")
2571
2709
  continue;
2572
- if (pending.deadlineTimer)
2573
- this.timerClearer(pending.deadlineTimer);
2574
- if (this.runningValidateKey === key)
2575
- this.runningValidateKey = undefined;
2576
- if (this.validateBarrierKey === key)
2577
- this.validateBarrierKey = undefined;
2578
- // Forwarded entries stay in pendingRequests until writeSyntheticReply
2579
- // settles them (the FORWARDED pending is what entitles the id to a
2580
- // finality tombstone). Cancelled entries are already gone — the
2581
- // cancellation settled them terminally at admission.
2582
- const toolName = pending.toolName ?? "unknown";
2583
- const pruned = prunedByTool.get(toolName) ?? [];
2584
- const { reply } = buildWorkerRestartReply(pending, exit, now, pruned, { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
2585
- this.writeSyntheticReply(pending, reply);
2710
+ this.runRecoveryStep("worker_exit.fail_pending_cleanup", () => {
2711
+ if (this.runningValidateKey === key)
2712
+ this.runningValidateKey = undefined;
2713
+ if (this.validateBarrierKey === key)
2714
+ this.validateBarrierKey = undefined;
2715
+ // Nil the field immediately after clearing, matching the convention
2716
+ // releaseForwardedRequest already uses. writeSyntheticReply (below,
2717
+ // in the SEPARATE fail_pending_reply step) also does
2718
+ // `if (pending.deadlineTimer) this.timerClearer(...)` before it
2719
+ // deletes the entry from pendingRequests. If this step cleared the
2720
+ // timer but left the field set, that second check would still be
2721
+ // true and writeSyntheticReply would attempt a REDUNDANT second
2722
+ // clear of the SAME already-cleared timer. A timerClearer that
2723
+ // faults on a repeat invocation for the same timer would then throw
2724
+ // BEFORE writeSyntheticReply's delete/tombstone — unlike a fault
2725
+ // strictly after deletion (which only loses that one reply), this
2726
+ // would leave the entry live in pendingRequests forever, with
2727
+ // nothing left to remove or answer it. Nilling here makes
2728
+ // writeSyntheticReply's own check false, so it never attempts that
2729
+ // second clear at all.
2730
+ if (pending.deadlineTimer) {
2731
+ this.timerClearer(pending.deadlineTimer);
2732
+ pending.deadlineTimer = undefined;
2733
+ }
2734
+ });
2735
+ this.runRecoveryStep("worker_exit.fail_pending_reply", () => {
2736
+ // Forwarded entries stay in pendingRequests until writeSyntheticReply
2737
+ // settles them (the FORWARDED pending is what entitles the id to a
2738
+ // finality tombstone). Cancelled entries are already gone — the
2739
+ // cancellation settled them terminally at admission.
2740
+ const toolName = pending.toolName ?? "unknown";
2741
+ const pruned = prunedByTool.get(toolName) ?? [];
2742
+ const { reply } = buildWorkerRestartReply(pending, exit, now, pruned, { structuredRestartDisabled: STRUCTURED_RESTART_DISABLED });
2743
+ this.writeSyntheticReply(pending, reply);
2744
+ });
2586
2745
  }
2587
2746
  }
2588
2747
  /**
@@ -2688,7 +2847,7 @@ export class StdioSupervisor {
2688
2847
  }
2689
2848
  catch (error) {
2690
2849
  this.eventWriter("warn", "supervisor.client_write_error", {
2691
- message: error instanceof Error ? error.message : String(error)
2850
+ message: describeThrown(error)
2692
2851
  });
2693
2852
  }
2694
2853
  }
@@ -135,7 +135,11 @@ export function openDatabase(config, logger = buildDefaultLogger()) {
135
135
  path: config.sqlitePath,
136
136
  reason: errorMessage
137
137
  });
138
- throw caughtError;
138
+ throw createError({
139
+ code: ERROR_CODES.DB_FAILURE,
140
+ message: `Failed to open SQLite database at ${config.sqlitePath}: ${errorMessage}`,
141
+ details: { sqlitePath: config.sqlitePath, reason: caughtError?.code }
142
+ });
139
143
  }
140
144
  // The rebuild runs INSIDE the handler for the failure it is recovering
141
145
  // from, so it needs a guard of its own: without one a throw from here left
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhisang/minecraft-modding-mcp",
3
- "version": "7.0.0-rc.3",
3
+ "version": "7.0.0",
4
4
  "description": "MCP server for AI-assisted Minecraft modding: inspect decompiled source, resolve Mojang/Yarn/Intermediary mappings, diff versions, analyze Fabric/Forge/NeoForge mod JARs, and validate Mixin, Access Widener, and Access Transformer files.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.1",