@adhisang/minecraft-modding-mcp 4.2.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.2.1] - 2026-05-23
9
+
10
+ ### Fixed
11
+ - Mojang binary-remap cache writes recover from corrupt `<cacheDir>/remapped/<artifactId>.jar` directories instead of surfacing raw `ENOTEMPTY`, and `manage-cache` can list and delete those corrupt `binary-remap` entries by `selector.artifactId`.
12
+ - `isAppError(value)` now narrows to `AppError` via `instanceof AppError` instead of `Error & { code: string }`. Plain `Error` objects that happen to carry a `code` field no longer pass the guard, so unrelated errors can no longer leak through `AppError`-specific error handling.
13
+ - Java NBT `TAG_String` now encodes/decodes via MUTF-8 to match Mojang's `DataInputStream.readUTF` / `DataOutputStream.writeUTF` contract. Supplementary characters (emoji, supplementary CJK) and the NUL character (`U+0000`) now round-trip correctly. ASCII and common BMP byte sequences are byte-identical to the previous UTF-8 output, so existing NBT payloads are unaffected.
14
+
8
15
  ## [4.2.0] - 2026-05-17
9
16
 
10
17
  ### Added
package/README.md CHANGED
@@ -164,6 +164,7 @@ These notes cover high-frequency decisions during onboarding. For the full pitfa
164
164
  - `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.
165
165
  - `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.
166
166
  - 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.
167
+ - `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.
167
168
 
168
169
  ### Inspect Minecraft source from a version
169
170
 
@@ -442,6 +442,9 @@ function workspaceCacheEntries(workspaceCache) {
442
442
  }));
443
443
  }
444
444
  async function fileBackedEntries(config, cacheKind) {
445
+ if (cacheKind === "binary-remap") {
446
+ return binaryRemapEntries(config);
447
+ }
445
448
  const root = kindRoot(config, cacheKind);
446
449
  const files = await listFilesRecursive(root);
447
450
  const entries = [];
@@ -466,17 +469,91 @@ async function fileBackedEntries(config, cacheKind) {
466
469
  inUse: filePath.endsWith(".lock") ||
467
470
  filePath.endsWith(".wal") ||
468
471
  filePath.endsWith(".journal"),
469
- ...(cacheKind === "downloads" || cacheKind === "mod-remap" || cacheKind === "binary-remap"
472
+ ...(cacheKind === "downloads" || cacheKind === "mod-remap"
470
473
  ? { jarPath: filePath }
471
- : {}),
472
- ...(cacheKind === "binary-remap"
473
- ? { artifactId: normalizedEntryId.replace(/\.jar$/i, "") }
474
474
  : {})
475
475
  }
476
476
  });
477
477
  }
478
478
  return entries;
479
479
  }
480
+ /**
481
+ * Binary-remap cache entries are keyed by the final artifact id even when the
482
+ * on-disk entry is a corrupt final directory or a leftover temp path.
483
+ */
484
+ function parseBinaryRemapEntryName(name) {
485
+ const legacyTempMatch = /^(.+)\.jar\.tmp\..+$/.exec(name);
486
+ if (legacyTempMatch?.[1]) {
487
+ return { artifactId: legacyTempMatch[1], corrupt: true };
488
+ }
489
+ const tempMatch = /^(.+)\.tmp\.\d+\.\d+\.[A-Za-z0-9_-]+\.jar$/.exec(name);
490
+ if (tempMatch?.[1]) {
491
+ return { artifactId: tempMatch[1], corrupt: true };
492
+ }
493
+ const finalJarMatch = /^(.+)\.jar$/.exec(name);
494
+ if (finalJarMatch?.[1]) {
495
+ return { artifactId: finalJarMatch[1], corrupt: false };
496
+ }
497
+ return undefined;
498
+ }
499
+ async function binaryRemapEntries(config) {
500
+ const root = kindRoot(config, "binary-remap");
501
+ if (!existsSync(root)) {
502
+ return [];
503
+ }
504
+ const entries = [];
505
+ // Do not recurse here: a corrupt `<artifactId>.jar` directory must remain one
506
+ // selectable cache entry so `selector.artifactId` can remove it recursively.
507
+ const dirents = await readdir(root, { withFileTypes: true });
508
+ for (const entry of dirents.sort((left, right) => left.name.localeCompare(right.name))) {
509
+ if (!entry.isFile() && !entry.isDirectory()) {
510
+ continue;
511
+ }
512
+ const parsed = parseBinaryRemapEntryName(entry.name);
513
+ if (!parsed) {
514
+ continue;
515
+ }
516
+ const filePath = join(root, entry.name);
517
+ const fileStat = await stat(filePath);
518
+ const corrupt = parsed.corrupt || entry.isDirectory();
519
+ const sizeBytes = entry.isDirectory()
520
+ ? await directoryFileSizeBytes(filePath)
521
+ : fileStat.size;
522
+ entries.push({
523
+ cacheKind: "binary-remap",
524
+ entryId: entry.name,
525
+ path: filePath,
526
+ sizeBytes,
527
+ status: corrupt ? "corrupt" : "healthy",
528
+ meta: {
529
+ updatedAt: fileStat.mtime.toISOString(),
530
+ version: inferVersion(filePath, entry.name),
531
+ mapping: inferMapping(filePath, entry.name),
532
+ scope: inferScope(filePath, entry.name),
533
+ projectPath: inferProjectPath(filePath, config.pathRuntimeInfo),
534
+ partial: entry.isFile() && fileStat.size === 0,
535
+ corrupt,
536
+ inUse: false,
537
+ jarPath: filePath,
538
+ artifactId: parsed.artifactId
539
+ }
540
+ });
541
+ }
542
+ return entries;
543
+ }
544
+ async function directoryFileSizeBytes(root) {
545
+ const files = await listFilesRecursive(root);
546
+ let totalBytes = 0;
547
+ for (const filePath of files) {
548
+ try {
549
+ totalBytes += (await stat(filePath)).size;
550
+ }
551
+ catch {
552
+ // The entry may disappear during cleanup or a concurrent cache write.
553
+ }
554
+ }
555
+ return totalBytes;
556
+ }
480
557
  export function createCacheRegistry(config) {
481
558
  const workspaceCache = config.workspaceContextCache ?? getProcessWorkspaceContextCache();
482
559
  async function collectEntries(cacheKinds, selector) {
@@ -564,7 +641,9 @@ export function createCacheRegistry(config) {
564
641
  continue;
565
642
  }
566
643
  if (existsSync(entry.path)) {
567
- await rm(entry.path, { force: true });
644
+ // Only binary-remap inventory can return directories as entries;
645
+ // other file-backed kinds keep their existing file-only contract.
646
+ await rm(entry.path, { recursive: entry.cacheKind === "binary-remap", force: true });
568
647
  }
569
648
  }
570
649
  }
package/dist/errors.js CHANGED
@@ -49,7 +49,10 @@ export class AppError extends Error {
49
49
  }
50
50
  }
51
51
  export const createError = (payload) => new AppError(payload);
52
+ // Narrow strictly by class identity. Duck-typing on `code` would also accept
53
+ // arbitrary Errors whose `details` may leak unsanitised context (paths, tokens)
54
+ // into AppError handling paths.
52
55
  export const isAppError = (value) => {
53
- return value instanceof Error && value.code !== undefined;
56
+ return value instanceof AppError;
54
57
  };
55
58
  //# sourceMappingURL=errors.js.map
@@ -1,3 +1,4 @@
1
+ export declare const MAX_STDIO_SNAPSHOT = 6240;
1
2
  export interface JavaProcessOptions {
2
3
  jarPath: string;
3
4
  args: string[];
@@ -12,5 +13,13 @@ export interface JavaProcessResult {
12
13
  stdoutTail: string;
13
14
  stderrTail: string;
14
15
  }
16
+ export declare function limitStdio(text: string): string;
17
+ export declare function isAbsolutePath(value: string): boolean;
18
+ export declare function isOptionArg(value: string): boolean;
19
+ export declare function normalizeArgs(args: string[]): string[];
15
20
  export declare function assertJavaAvailable(): Promise<void>;
16
21
  export declare function runJavaProcess(options: JavaProcessOptions): Promise<JavaProcessResult>;
22
+ export declare const javaRunner: {
23
+ run: (options: JavaProcessOptions) => Promise<JavaProcessResult>;
24
+ assertAvailable: () => Promise<void>;
25
+ };
@@ -2,20 +2,20 @@ import { spawn } from "node:child_process";
2
2
  import { createError, ERROR_CODES } from "./errors.js";
3
3
  import { normalizePathForHost } from "./path-converter.js";
4
4
  const JAVA_CHECK_TIMEOUT_MS = 2_000;
5
- const MAX_STDIO_SNAPSHOT = 6_240;
6
- function limitStdio(text) {
5
+ export const MAX_STDIO_SNAPSHOT = 6_240;
6
+ export function limitStdio(text) {
7
7
  if (text.length <= MAX_STDIO_SNAPSHOT) {
8
8
  return text;
9
9
  }
10
10
  return text.slice(-MAX_STDIO_SNAPSHOT);
11
11
  }
12
- function isAbsolutePath(value) {
12
+ export function isAbsolutePath(value) {
13
13
  return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
14
14
  }
15
- function isOptionArg(value) {
15
+ export function isOptionArg(value) {
16
16
  return value.startsWith("-");
17
17
  }
18
- function normalizeArgs(args) {
18
+ export function normalizeArgs(args) {
19
19
  return args.map((arg) => {
20
20
  if (isOptionArg(arg)) {
21
21
  return arg;
@@ -117,4 +117,10 @@ export function runJavaProcess(options) {
117
117
  });
118
118
  });
119
119
  }
120
+ // Mutable container that lets tests stub the Java subprocess boundary via
121
+ // `mock.method(javaRunner, "run", ...)` without rewriting ESM named imports.
122
+ export const javaRunner = {
123
+ run: runJavaProcess,
124
+ assertAvailable: assertJavaAvailable
125
+ };
120
126
  //# sourceMappingURL=java-process.js.map
@@ -37,6 +37,85 @@ function resolveTagName(tagId, pointer) {
37
37
  }
38
38
  return tagName;
39
39
  }
40
+ // Java NBT TAG_String uses MUTF-8 (Mojang's on-disk format matches the JVM
41
+ // `DataInput/OutputStream.{read,write}UTF` contract), which differs from UTF-8
42
+ // for two ranges: `U+0000` encodes as `C0 80`, and supplementary code points
43
+ // (`U+10000`+) encode as two 3-byte sequences for the UTF-16 surrogate pair
44
+ // (6 bytes total). All other characters share the UTF-8 byte sequence.
45
+ function mutf8ByteLength(value) {
46
+ let length = 0;
47
+ for (let i = 0; i < value.length; i += 1) {
48
+ const c = value.charCodeAt(i);
49
+ if (c === 0x0000) {
50
+ length += 2;
51
+ }
52
+ else if (c <= 0x007f) {
53
+ length += 1;
54
+ }
55
+ else if (c <= 0x07ff) {
56
+ length += 2;
57
+ }
58
+ else {
59
+ length += 3;
60
+ }
61
+ }
62
+ return length;
63
+ }
64
+ function encodeMutf8(value) {
65
+ const buffer = Buffer.allocUnsafe(mutf8ByteLength(value));
66
+ let offset = 0;
67
+ for (let i = 0; i < value.length; i += 1) {
68
+ const c = value.charCodeAt(i);
69
+ if (c === 0x0000) {
70
+ buffer[offset++] = 0xc0;
71
+ buffer[offset++] = 0x80;
72
+ }
73
+ else if (c <= 0x007f) {
74
+ buffer[offset++] = c;
75
+ }
76
+ else if (c <= 0x07ff) {
77
+ buffer[offset++] = 0xc0 | (c >> 6);
78
+ buffer[offset++] = 0x80 | (c & 0x3f);
79
+ }
80
+ else {
81
+ buffer[offset++] = 0xe0 | (c >> 12);
82
+ buffer[offset++] = 0x80 | ((c >> 6) & 0x3f);
83
+ buffer[offset++] = 0x80 | (c & 0x3f);
84
+ }
85
+ }
86
+ return buffer;
87
+ }
88
+ function decodeMutf8(buffer, start, end) {
89
+ const codeUnits = [];
90
+ let i = start;
91
+ while (i < end) {
92
+ const b0 = buffer[i++];
93
+ if ((b0 & 0x80) === 0) {
94
+ codeUnits.push(b0);
95
+ }
96
+ else if ((b0 & 0xe0) === 0xc0) {
97
+ if (i >= end) {
98
+ throw parseError("Truncated MUTF-8 2-byte sequence.", { offset: i - 1 });
99
+ }
100
+ const b1 = buffer[i++];
101
+ codeUnits.push(((b0 & 0x1f) << 6) | (b1 & 0x3f));
102
+ }
103
+ else if ((b0 & 0xf0) === 0xe0) {
104
+ if (i + 1 >= end) {
105
+ throw parseError("Truncated MUTF-8 3-byte sequence.", { offset: i - 1 });
106
+ }
107
+ const b1 = buffer[i++];
108
+ const b2 = buffer[i++];
109
+ codeUnits.push(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f));
110
+ }
111
+ else {
112
+ throw parseError("Invalid MUTF-8 lead byte.", { byte: b0, offset: i - 1 });
113
+ }
114
+ }
115
+ // NBT TAG_String is bounded by uint16 length so codeUnits.length <= 65535,
116
+ // safely within String.fromCharCode's argument cap.
117
+ return String.fromCharCode(...codeUnits);
118
+ }
40
119
  class NbtReader {
41
120
  buffer;
42
121
  offset = 0;
@@ -103,7 +182,7 @@ class NbtReader {
103
182
  readString() {
104
183
  const byteLength = this.readUInt16();
105
184
  this.ensure(byteLength);
106
- const value = this.buffer.toString("utf8", this.offset, this.offset + byteLength);
185
+ const value = decodeMutf8(this.buffer, this.offset, this.offset + byteLength);
107
186
  this.offset += byteLength;
108
187
  return value;
109
188
  }
@@ -154,7 +233,7 @@ class NbtWriter {
154
233
  this.chunks.push(chunk);
155
234
  }
156
235
  writeString(value) {
157
- const encoded = Buffer.from(value, "utf8");
236
+ const encoded = encodeMutf8(value);
158
237
  if (encoded.length > 0xffff) {
159
238
  throw encodeError("NBT string length exceeds uint16.", { byteLength: encoded.length });
160
239
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
1
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { log } from "../logger.js";
4
4
  export function hasAnyFiles(svc, artifactId) {
@@ -17,7 +17,7 @@ export function unlinkRemappedJarForArtifact(svc, artifactId) {
17
17
  const remappedJarPath = join(svc.config.cacheDir, "remapped", `${artifactId}.jar`);
18
18
  try {
19
19
  if (existsSync(remappedJarPath)) {
20
- unlinkSync(remappedJarPath);
20
+ rmSync(remappedJarPath, { recursive: true, force: true });
21
21
  }
22
22
  }
23
23
  catch {
@@ -1,7 +1,22 @@
1
+ import { resolveMojangTinyFile } from "../mojang-tiny-mapping-service.js";
1
2
  import type { SourceService } from "../source-service.js";
2
3
  import type { ArtifactIndexMetaRow } from "../storage/index-meta-repo.js";
3
4
  import type { ArtifactRow, ResolvedSourceArtifact } from "../types.js";
5
+ import { remapJar } from "../tiny-remapper-service.js";
6
+ import { resolveTinyRemapperJar } from "../tiny-remapper-resolver.js";
4
7
  export declare const INDEX_SCHEMA_VERSION = 1;
8
+ /**
9
+ * Injectable dependencies let remap-cache regression tests run without Java.
10
+ * Production callers use `defaultBinaryRemapDeps`; this is not a public MCP
11
+ * extension point.
12
+ */
13
+ export type BinaryRemapDeps = {
14
+ resolveTinyRemapperJar: typeof resolveTinyRemapperJar;
15
+ resolveMojangTinyFile: typeof resolveMojangTinyFile;
16
+ remapJar: typeof remapJar;
17
+ now: () => number;
18
+ randomSuffix: () => string;
19
+ };
5
20
  export type IndexRebuildReason = "force" | "missing_meta" | "schema_mismatch" | "signature_mismatch" | "already_current";
6
21
  export interface IndexedFileRecord {
7
22
  filePath: string;
@@ -57,13 +72,12 @@ export declare function ingestIfNeeded(svc: SourceService, resolved: ResolvedSou
57
72
  * binary remap, run tiny-remapper now and return the remapped jar path.
58
73
  * Otherwise return the original binaryJarPath unchanged.
59
74
  *
60
- * Cache safety: writes to a per-attempt temp file then atomic-renames into
61
- * <cacheDir>/remapped/<artifactId>.jar. A per-target inflight Promise map
62
- * collapses concurrent calls so two simultaneous resolveArtifact calls for
63
- * the same artifactId share one tiny-remapper run instead of racing on the
64
- * same output path.
75
+ * Cache safety: writes to a per-attempt `.jar` temp path, validates ZIP magic,
76
+ * then atomic-renames into <cacheDir>/remapped/<artifactId>.jar. A per-target
77
+ * inflight Promise map collapses concurrent calls so simultaneous
78
+ * resolveArtifact calls for the same artifactId share one tiny-remapper run.
65
79
  */
66
- export declare function maybeRemapBinaryForMojang(svc: SourceService, resolved: ResolvedSourceArtifact): Promise<string>;
80
+ export declare function maybeRemapBinaryForMojang(svc: SourceService, resolved: ResolvedSourceArtifact, deps?: BinaryRemapDeps): Promise<string>;
67
81
  export declare function recordRemappedJarBytesFromDisk(svc: SourceService, artifactId: string, path: string): Promise<void>;
68
82
  /**
69
83
  * Best-effort structural check that `path` is a non-empty file beginning with
@@ -73,10 +87,21 @@ export declare function recordRemappedJarBytesFromDisk(svc: SourceService, artif
73
87
  * are not (a corrupt cache hit must be evicted).
74
88
  */
75
89
  export declare function isUsableJarFile(path: string): Promise<boolean>;
90
+ export declare function buildBinaryRemapTempPath(remappedJarPath: string, input: {
91
+ pid: number;
92
+ now: number;
93
+ randomSuffix: string;
94
+ }): string;
76
95
  export declare function runBinaryRemap(svc: SourceService, input: {
77
96
  version: string;
78
97
  inputJar: string;
79
98
  remappedDir: string;
80
99
  remappedJarPath: string;
81
100
  }): Promise<string>;
101
+ export declare function runBinaryRemapWithDeps(svc: SourceService, input: {
102
+ version: string;
103
+ inputJar: string;
104
+ remappedDir: string;
105
+ remappedJarPath: string;
106
+ }, deps: BinaryRemapDeps): Promise<string>;
82
107
  export declare function loadFromSourceJar(svc: SourceService, sourceJarPath: string): Promise<IndexedFileRecord[]>;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, open, rename, stat, unlink } from "node:fs/promises";
4
- import { join } from "node:path";
3
+ import { mkdir, open, rename, rm, stat } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
5
  import { buildSuggestedCall } from "../build-suggested-call.js";
6
6
  import { decompileBinaryJar } from "../decompiler/vineflower.js";
7
7
  import { ERROR_CODES, createError, isAppError } from "../errors.js";
@@ -15,6 +15,13 @@ import { resolveVineflowerJar } from "../vineflower-resolver.js";
15
15
  import { enforceCacheLimits, recordRemappedJarBytes, releaseRemappedJarBytes, touchCacheMetrics, upsertCacheMetrics } from "./cache-metrics.js";
16
16
  import { normalizePathStyle } from "./shared-utils.js";
17
17
  export const INDEX_SCHEMA_VERSION = 1;
18
+ const defaultBinaryRemapDeps = {
19
+ resolveTinyRemapperJar,
20
+ resolveMojangTinyFile,
21
+ remapJar,
22
+ now: () => Date.now(),
23
+ randomSuffix: () => Math.random().toString(36).slice(2, 8) || "0"
24
+ };
18
25
  function chunkArray(items, chunkSize) {
19
26
  const size = Math.max(1, Math.trunc(chunkSize));
20
27
  if (items.length === 0) {
@@ -354,13 +361,12 @@ async function rebuildMissingArtifactIndex(svc, resolved, reason) {
354
361
  * binary remap, run tiny-remapper now and return the remapped jar path.
355
362
  * Otherwise return the original binaryJarPath unchanged.
356
363
  *
357
- * Cache safety: writes to a per-attempt temp file then atomic-renames into
358
- * <cacheDir>/remapped/<artifactId>.jar. A per-target inflight Promise map
359
- * collapses concurrent calls so two simultaneous resolveArtifact calls for
360
- * the same artifactId share one tiny-remapper run instead of racing on the
361
- * same output path.
364
+ * Cache safety: writes to a per-attempt `.jar` temp path, validates ZIP magic,
365
+ * then atomic-renames into <cacheDir>/remapped/<artifactId>.jar. A per-target
366
+ * inflight Promise map collapses concurrent calls so simultaneous
367
+ * resolveArtifact calls for the same artifactId share one tiny-remapper run.
362
368
  */
363
- export async function maybeRemapBinaryForMojang(svc, resolved) {
369
+ export async function maybeRemapBinaryForMojang(svc, resolved, deps = defaultBinaryRemapDeps) {
364
370
  const binaryJarPath = resolved.binaryJarPath;
365
371
  if (!binaryJarPath) {
366
372
  throw createError({
@@ -400,10 +406,19 @@ export async function maybeRemapBinaryForMojang(svc, resolved) {
400
406
  remappedJarPath
401
407
  });
402
408
  try {
403
- await unlink(remappedJarPath);
409
+ await rm(remappedJarPath, { recursive: true, force: true });
404
410
  }
405
- catch {
406
- // ignore: race with another process or already-deleted file.
411
+ catch (caughtError) {
412
+ releaseRemappedJarBytes(svc, resolved.artifactId);
413
+ throw createError({
414
+ code: ERROR_CODES.REMAP_FAILED,
415
+ message: "Failed to remove corrupt binary remap cache entry.",
416
+ details: {
417
+ artifactId: resolved.artifactId,
418
+ remappedJarPath,
419
+ cause: caughtError instanceof Error ? caughtError.message : String(caughtError)
420
+ }
421
+ });
407
422
  }
408
423
  releaseRemappedJarBytes(svc, resolved.artifactId);
409
424
  }
@@ -411,12 +426,12 @@ export async function maybeRemapBinaryForMojang(svc, resolved) {
411
426
  if (inflight) {
412
427
  return inflight;
413
428
  }
414
- const remapPromise = runBinaryRemap(svc, {
429
+ const remapPromise = runBinaryRemapWithDeps(svc, {
415
430
  version: resolved.version,
416
431
  inputJar: binaryJarPath,
417
432
  remappedDir,
418
433
  remappedJarPath
419
- });
434
+ }, deps);
420
435
  svc.state.inflightRemaps.set(remappedJarPath, remapPromise);
421
436
  try {
422
437
  const path = await remapPromise;
@@ -430,6 +445,9 @@ export async function maybeRemapBinaryForMojang(svc, resolved) {
430
445
  export async function recordRemappedJarBytesFromDisk(svc, artifactId, path) {
431
446
  try {
432
447
  const fileStat = await stat(path);
448
+ if (!fileStat.isFile()) {
449
+ return;
450
+ }
433
451
  recordRemappedJarBytes(svc, artifactId, fileStat.size);
434
452
  }
435
453
  catch {
@@ -467,14 +485,45 @@ export async function isUsableJarFile(path) {
467
485
  await handle?.close().catch(() => undefined);
468
486
  }
469
487
  }
488
+ export function buildBinaryRemapTempPath(remappedJarPath, input) {
489
+ // Keep the final extension as `.jar`; the remap output is rejected unless it
490
+ // is a regular ZIP/JAR file, and manage-cache recognizes this temp shape.
491
+ const fileName = basename(remappedJarPath);
492
+ const artifactId = fileName.endsWith(".jar")
493
+ ? fileName.slice(0, -".jar".length)
494
+ : fileName;
495
+ return join(dirname(remappedJarPath), `${artifactId}.tmp.${input.pid}.${input.now}.${input.randomSuffix}.jar`);
496
+ }
497
+ async function describePathKind(path) {
498
+ try {
499
+ const stats = await stat(path);
500
+ if (stats.isFile()) {
501
+ return "file";
502
+ }
503
+ if (stats.isDirectory()) {
504
+ return "directory";
505
+ }
506
+ return "other";
507
+ }
508
+ catch {
509
+ return "missing";
510
+ }
511
+ }
470
512
  export async function runBinaryRemap(svc, input) {
471
- const tinyRemapperJarPath = await resolveTinyRemapperJar(svc.config.cacheDir, svc.config.tinyRemapperJarPath);
472
- const mojangTiny = await resolveMojangTinyFile(input.version, svc.config);
513
+ return runBinaryRemapWithDeps(svc, input, defaultBinaryRemapDeps);
514
+ }
515
+ export async function runBinaryRemapWithDeps(svc, input, deps) {
516
+ const tinyRemapperJarPath = await deps.resolveTinyRemapperJar(svc.config.cacheDir, svc.config.tinyRemapperJarPath);
517
+ const mojangTiny = await deps.resolveMojangTinyFile(input.version, svc.config);
473
518
  await mkdir(input.remappedDir, { recursive: true });
474
- const tempPath = `${input.remappedJarPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
519
+ const tempPath = buildBinaryRemapTempPath(input.remappedJarPath, {
520
+ pid: process.pid,
521
+ now: deps.now(),
522
+ randomSuffix: deps.randomSuffix()
523
+ });
475
524
  const remapStartedAt = Date.now();
476
525
  try {
477
- await remapJar(tinyRemapperJarPath, {
526
+ await deps.remapJar(tinyRemapperJarPath, {
478
527
  inputJar: input.inputJar,
479
528
  outputJar: tempPath,
480
529
  mappingsFile: mojangTiny.path,
@@ -483,20 +532,38 @@ export async function runBinaryRemap(svc, input) {
483
532
  timeoutMs: svc.config.remapTimeoutMs,
484
533
  maxMemoryMb: svc.config.remapMaxMemoryMb
485
534
  });
486
- const tempStats = await stat(tempPath);
487
- if (tempStats.size === 0) {
535
+ if (!(await isUsableJarFile(tempPath))) {
536
+ throw createError({
537
+ code: ERROR_CODES.REMAP_FAILED,
538
+ message: "tiny-remapper produced an invalid output jar.",
539
+ details: {
540
+ inputJar: input.inputJar,
541
+ tempPath,
542
+ outputKind: await describePathKind(tempPath)
543
+ }
544
+ });
545
+ }
546
+ try {
547
+ await rename(tempPath, input.remappedJarPath);
548
+ }
549
+ catch (caughtError) {
488
550
  throw createError({
489
551
  code: ERROR_CODES.REMAP_FAILED,
490
- message: "tiny-remapper produced an empty output jar.",
491
- details: { inputJar: input.inputJar, tempPath }
552
+ message: "Failed to finalize binary remap cache entry.",
553
+ details: {
554
+ inputJar: input.inputJar,
555
+ tempPath,
556
+ remappedJarPath: input.remappedJarPath,
557
+ outputKind: await describePathKind(input.remappedJarPath),
558
+ cause: caughtError instanceof Error ? caughtError.message : String(caughtError)
559
+ }
492
560
  });
493
561
  }
494
- await rename(tempPath, input.remappedJarPath);
495
562
  return input.remappedJarPath;
496
563
  }
497
564
  catch (caughtError) {
498
565
  try {
499
- await unlink(tempPath);
566
+ await rm(tempPath, { recursive: true, force: true });
500
567
  }
501
568
  catch {
502
569
  // tempPath may not exist if remapJar failed before writing anything; ignore.
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { createError, ERROR_CODES } from "./errors.js";
3
- import { assertJavaAvailable, runJavaProcess } from "./java-process.js";
3
+ import { javaRunner } from "./java-process.js";
4
4
  import { log } from "./logger.js";
5
5
  export async function remapJar(tinyRemapperJarPath, options) {
6
6
  const { inputJar, outputJar, mappingsFile, fromNamespace, toNamespace, threads = 4, rebuildSourceFilenames = false, timeoutMs = 600_000, maxMemoryMb = 4096 } = options;
7
- await assertJavaAvailable();
7
+ await javaRunner.assertAvailable();
8
8
  log("info", "remap.start", {
9
9
  inputJar,
10
10
  outputJar,
@@ -21,7 +21,7 @@ export async function remapJar(tinyRemapperJarPath, options) {
21
21
  args.push("--rebuildSourceFilenames");
22
22
  }
23
23
  try {
24
- const result = await runJavaProcess({
24
+ const result = await javaRunner.run({
25
25
  jarPath: tinyRemapperJarPath,
26
26
  args,
27
27
  timeoutMs,
@@ -72,6 +72,7 @@ Workspace detection is memoised in a process-resident `WorkspaceContextCache` (1
72
72
  ## Common Pitfalls
73
73
 
74
74
  - `mapping="mojang"` requires source-backed artifacts on legacy obfuscated versions. For unobfuscated releases such as `26.1+`, the runtime/decompile path is accepted directly for version and versioned-coordinate targets. When source jars are not available but the version's Mojang tiny mappings, the tiny-remapper jar, and `MappingService.checkMappingHealth` are all healthy, `resolve-artifact` with `target.kind="version"` will transparently tiny-remap the binary jar (`obfuscated -> mojang`) and decompile the result, and the response carries `qualityFlags` `"binary-remapped"` and `"decompiled"` plus `provenance.transformChain` `"binary-remap:obf->mojang"` and `"decompile:vineflower"`. Coordinate and jar targets are not eligible for this fallback and still surface `ERR_MAPPING_NOT_APPLIED`. Source-backed and obfuscated artifacts keep their existing `artifactId` hashes — only the new mojang-remapped variant lives in a separate cache slot.
75
+ - Mojang binary-remap cache entries live under `<cacheDir>/remapped`. `manage-cache` lists valid files plus corrupt directories and leftover temp entries under `cacheKinds: ["binary-remap"]`; corrupt entries carry `status: "corrupt"` and `meta.artifactId`, so callers can preview or apply cleanup with `selector.artifactId`.
75
76
  - `list-artifact-files` indexes Java source paths only. Probing `assets/` or `data/` prefixes will not return non-Java resources.
76
77
  - `search-class-source` defaults to `queryMode="auto"`. Use `queryMode="literal"` for explicit substring scans. `match="regex"` enforces `query.length <= 200` and caps results at `100`.
77
78
  - `search-class-source` returns compact hits only. Use `get-artifact-file` or `get-class-source` to inspect returned files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhisang/minecraft-modding-mcp",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
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
  "main": "dist/index.js",