@adhisang/minecraft-modding-mcp 4.2.1 → 6.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +5 -4
  3. package/dist/access-transformer-parser.d.ts +2 -1
  4. package/dist/access-transformer-parser.js +10 -1
  5. package/dist/access-widener-parser.d.ts +1 -0
  6. package/dist/access-widener-parser.js +6 -3
  7. package/dist/build-suggested-call.js +31 -10
  8. package/dist/cache-registry.d.ts +8 -0
  9. package/dist/cache-registry.js +50 -32
  10. package/dist/config.d.ts +1 -0
  11. package/dist/config.js +4 -0
  12. package/dist/decompiler/vineflower.js +8 -2
  13. package/dist/entry-tools/analyze-mod-service.d.ts +24 -24
  14. package/dist/entry-tools/analyze-mod-service.js +2 -1
  15. package/dist/entry-tools/analyze-symbol-service.d.ts +57 -22
  16. package/dist/entry-tools/analyze-symbol-service.js +139 -34
  17. package/dist/entry-tools/batch-class-members-service.d.ts +3 -1
  18. package/dist/entry-tools/batch-class-members-service.js +6 -6
  19. package/dist/entry-tools/batch-class-source-service.d.ts +3 -1
  20. package/dist/entry-tools/batch-class-source-service.js +5 -6
  21. package/dist/entry-tools/batch-mappings-service.d.ts +3 -1
  22. package/dist/entry-tools/batch-mappings-service.js +4 -5
  23. package/dist/entry-tools/batch-symbol-exists-service.d.ts +3 -1
  24. package/dist/entry-tools/batch-symbol-exists-service.js +4 -5
  25. package/dist/entry-tools/compare-minecraft-service.d.ts +10 -10
  26. package/dist/entry-tools/compare-minecraft-service.js +1 -1
  27. package/dist/entry-tools/entry-tool-schema.d.ts +1 -1
  28. package/dist/entry-tools/inspect-minecraft/handlers/artifact.js +1 -3
  29. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +4 -3
  30. package/dist/entry-tools/inspect-minecraft/handlers/class-overview.js +0 -5
  31. package/dist/entry-tools/inspect-minecraft/handlers/class-source.js +0 -1
  32. package/dist/entry-tools/inspect-minecraft/handlers/file.js +26 -2
  33. package/dist/entry-tools/inspect-minecraft/handlers/list-files.js +0 -1
  34. package/dist/entry-tools/inspect-minecraft/handlers/search.d.ts +8 -0
  35. package/dist/entry-tools/inspect-minecraft/handlers/search.js +13 -2
  36. package/dist/entry-tools/inspect-minecraft/handlers/versions.d.ts +1 -1
  37. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +3 -2
  38. package/dist/entry-tools/inspect-minecraft/internal.d.ts +90 -89
  39. package/dist/entry-tools/inspect-minecraft/internal.js +32 -14
  40. package/dist/entry-tools/inspect-minecraft-service.d.ts +218 -218
  41. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  42. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  43. package/dist/entry-tools/response-contract.d.ts +3 -1
  44. package/dist/entry-tools/response-contract.js +3 -2
  45. package/dist/entry-tools/validate-project/cases/mixin.js +2 -1
  46. package/dist/entry-tools/validate-project/cases/project-summary.js +5 -3
  47. package/dist/entry-tools/validate-project-service.d.ts +42 -36
  48. package/dist/entry-tools/validate-project-service.js +3 -1
  49. package/dist/entry-tools/verify-mixin-target-service.d.ts +30 -0
  50. package/dist/entry-tools/verify-mixin-target-service.js +120 -29
  51. package/dist/error-mapping.d.ts +51 -0
  52. package/dist/error-mapping.js +157 -2
  53. package/dist/index.js +147 -76
  54. package/dist/java-process.js +1 -1
  55. package/dist/json-rpc-framing.d.ts +4 -0
  56. package/dist/json-rpc-framing.js +23 -1
  57. package/dist/mapping/internal-types.d.ts +24 -0
  58. package/dist/mapping/loaders/tiny-maven.js +46 -20
  59. package/dist/mapping/lookup.d.ts +13 -2
  60. package/dist/mapping/lookup.js +60 -2
  61. package/dist/mapping/types.d.ts +6 -0
  62. package/dist/mapping-service.d.ts +26 -2
  63. package/dist/mapping-service.js +212 -55
  64. package/dist/mcp-helpers.d.ts +1 -0
  65. package/dist/mcp-helpers.js +25 -3
  66. package/dist/minecraft-explorer-service.js +24 -11
  67. package/dist/mixin/access-validators.js +33 -1
  68. package/dist/mixin/annotation-validators.js +73 -9
  69. package/dist/mixin/parsed-validator.js +3 -2
  70. package/dist/mixin-parser.js +119 -7
  71. package/dist/mod-decompile-service.d.ts +2 -0
  72. package/dist/mod-decompile-service.js +21 -3
  73. package/dist/mod-remap-service.d.ts +20 -1
  74. package/dist/mod-remap-service.js +101 -45
  75. package/dist/mojang-tiny-mapping-service.js +5 -2
  76. package/dist/nbt/java-nbt-codec.js +46 -16
  77. package/dist/nbt/json-patch.js +51 -12
  78. package/dist/nbt/typed-json.d.ts +1 -0
  79. package/dist/nbt/typed-json.js +12 -2
  80. package/dist/nbt/types.d.ts +2 -2
  81. package/dist/observability.d.ts +6 -0
  82. package/dist/observability.js +10 -0
  83. package/dist/page-cursor.d.ts +17 -0
  84. package/dist/page-cursor.js +0 -0
  85. package/dist/path-converter.js +10 -3
  86. package/dist/registry-service.d.ts +2 -0
  87. package/dist/registry-service.js +41 -2
  88. package/dist/repo-downloader.js +4 -3
  89. package/dist/resources.js +45 -9
  90. package/dist/response-utils.d.ts +29 -4
  91. package/dist/response-utils.js +95 -5
  92. package/dist/source/access-validate.js +12 -2
  93. package/dist/source/artifact-resolver.js +2 -2
  94. package/dist/source/class-source/members-builder.d.ts +44 -1
  95. package/dist/source/class-source/members-builder.js +51 -4
  96. package/dist/source/class-source/snippet-builder.d.ts +13 -0
  97. package/dist/source/class-source/snippet-builder.js +54 -4
  98. package/dist/source/class-source-helpers.d.ts +4 -4
  99. package/dist/source/class-source-helpers.js +77 -20
  100. package/dist/source/class-source.d.ts +19 -0
  101. package/dist/source/class-source.js +144 -40
  102. package/dist/source/file-access.js +3 -2
  103. package/dist/source/indexer.js +29 -9
  104. package/dist/source/lifecycle/mapping-helpers.js +28 -3
  105. package/dist/source/lifecycle/runtime-check.js +20 -6
  106. package/dist/source/search.d.ts +1 -1
  107. package/dist/source/search.js +117 -30
  108. package/dist/source/validate-mixin/pipeline/resolve.js +23 -3
  109. package/dist/source/validate-mixin.d.ts +2 -1
  110. package/dist/source/validate-mixin.js +45 -4
  111. package/dist/source/workspace-target.js +17 -10
  112. package/dist/source-jar-reader.d.ts +54 -1
  113. package/dist/source-jar-reader.js +121 -10
  114. package/dist/source-resolver.js +2 -2
  115. package/dist/source-service.d.ts +28 -7
  116. package/dist/source-service.js +9 -1
  117. package/dist/stdio-supervisor.d.ts +2 -2
  118. package/dist/stdio-supervisor.js +29 -9
  119. package/dist/storage/db.js +24 -1
  120. package/dist/storage/files-repo.d.ts +15 -0
  121. package/dist/storage/files-repo.js +67 -37
  122. package/dist/storage/symbols-repo.d.ts +15 -0
  123. package/dist/storage/symbols-repo.js +53 -19
  124. package/dist/symbols/symbol-extractor.js +35 -6
  125. package/dist/text-truncate.d.ts +7 -0
  126. package/dist/text-truncate.js +21 -0
  127. package/dist/tiny-remapper-resolver.js +10 -0
  128. package/dist/tool-contract-manifest.js +2 -2
  129. package/dist/tool-execution-gate.js +12 -1
  130. package/dist/tool-guidance.d.ts +6 -2
  131. package/dist/tool-guidance.js +63 -13
  132. package/dist/tool-schemas.d.ts +561 -496
  133. package/dist/tool-schemas.js +175 -139
  134. package/dist/types.d.ts +1 -0
  135. package/dist/version-diff-service.js +5 -0
  136. package/dist/version-service.d.ts +3 -0
  137. package/dist/version-service.js +18 -2
  138. package/dist/warning-details.d.ts +30 -0
  139. package/dist/warning-details.js +80 -0
  140. package/dist/workspace-mapping-service.js +9 -0
  141. package/docs/README-ja.md +2 -2
  142. package/docs/examples.md +26 -2
  143. package/docs/tool-reference.md +22 -17
  144. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
- import { existsSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdir, rename, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { buildSuggestedCall } from "./build-suggested-call.js";
5
6
  import { createError, ERROR_CODES } from "./errors.js";
7
+ import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "./page-cursor.js";
6
8
  import { resolveGradleUserHomePath } from "./gradle-paths.js";
7
9
  import { defaultDownloadPath, downloadToCache } from "./repo-downloader.js";
8
10
  import { collectMatchedJarEntriesAsUtf8 } from "./source-jar-reader.js";
@@ -10,7 +12,7 @@ import { VersionService, isUnobfuscatedVersion } from "./version-service.js";
10
12
  import { buildSymbolKey, createClassSymbolRecord, mergeDirectionIndexes, normalizeMappedSymbolOutput } from "./mapping/parsers/symbol-records.js";
11
13
  import { buildAdjacency, buildTargetRecordIndex, pairKey } from "./mapping/parsers/normalize.js";
12
14
  import { MATCH_RANK, MAX_CANDIDATES } from "./mapping/internal-types.js";
13
- import { applyDisambiguationHints, clampRowLimit, collectTargetRecords, effectiveLoomSearchProjectPath, inferAmbiguityReasons, invalidInputError, limitResolutionCandidates, lookupCandidates, mappingPriorityFromInput, mappingSourceOrder, namespacePath, normalizeIncludedKinds, normalizeQuerySymbol, pathToTransformChain, pathUsesSource, projectLookupCandidateDescriptor, requiresOnlyObfuscatedMojangGraph, toLookupCandidate, toResolutionCandidate } from "./mapping/lookup.js";
15
+ import { applyDisambiguationHints, clampRowLimit, collectExactRecordIndex, collectTargetRecords, effectiveLoomSearchProjectPath, inferAmbiguityReasons, invalidInputError, limitResolutionCandidates, lookupCandidates, lookupExactClassBySimpleName, lookupExactClassBySymbol, lookupExactMembers, lookupExactMembersByOwner, mappingPriorityFromInput, mappingSourceOrder, namespacePath, normalizeIncludedKinds, normalizeQuerySymbol, pathToTransformChain, pathUsesSource, projectLookupCandidateDescriptor, requiresOnlyObfuscatedMojangGraph, toLookupCandidate, toResolutionCandidate } from "./mapping/lookup.js";
14
16
  import { loadMojangPairs } from "./mapping/loaders/mojang.js";
15
17
  import { loadTinyPairsFromLoom } from "./mapping/loaders/tiny-loom.js";
16
18
  import { loadTinyPairsFromMaven } from "./mapping/loaders/tiny-maven.js";
@@ -32,6 +34,8 @@ export class MappingService {
32
34
  static RESOLUTION_CACHE_TTL_MS = 5 * 60 * 1000;
33
35
  resolutionCacheHits = 0;
34
36
  resolutionCacheMisses = 0;
37
+ classProjectionComputes = 0;
38
+ apiMatrixRowMaps = 0;
35
39
  get resolutionCacheStats() {
36
40
  return {
37
41
  hits: this.resolutionCacheHits,
@@ -39,6 +43,14 @@ export class MappingService {
39
43
  size: this.resolutionCache.size
40
44
  };
41
45
  }
46
+ /** Test seam: counts genuine class-projection computes (cache misses). */
47
+ get classProjectionStats() {
48
+ return { computes: this.classProjectionComputes };
49
+ }
50
+ /** Test seam: counts genuine per-row cross-namespace path walks in getClassApiMatrix. */
51
+ get apiMatrixStats() {
52
+ return { rowMaps: this.apiMatrixRowMaps };
53
+ }
42
54
  constructor(config, versionService = new VersionService(config), fetchFn = globalThis.fetch) {
43
55
  this.config = config;
44
56
  this.versionService = versionService;
@@ -65,7 +77,7 @@ export class MappingService {
65
77
  // explicitly.
66
78
  const effectiveSignatureMode = input.signatureMode ?? "name-only";
67
79
  const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, effectiveSignatureMode, {
68
- allowShortClassName: input.kind === "class" && input.sourceMapping === "obfuscated"
80
+ allowShortClassName: input.kind === "class" && (input.sourceMapping === "obfuscated" || input.nameMode === "auto")
69
81
  });
70
82
  const cacheKey = this.buildResolutionCacheKey(version, input, querySymbol, effectiveSignatureMode);
71
83
  const cached = this.resolutionCache.get(cacheKey);
@@ -176,7 +188,7 @@ export class MappingService {
176
188
  }
177
189
  rawCandidates = rawCandidates.filter((candidate) => candidate.descriptor !== undefined && acceptedDescriptors.has(candidate.descriptor));
178
190
  }
179
- const disambiguatedCandidates = applyDisambiguationHints(rawCandidates, input.disambiguation);
191
+ const disambiguatedCandidates = applyDisambiguationHints(rawCandidates, input.disambiguation, warnings);
180
192
  if (rawCandidates.length > disambiguatedCandidates.length) {
181
193
  warnings.push(`Disambiguation hints narrowed candidates from ${rawCandidates.length} to ${disambiguatedCandidates.length}.`);
182
194
  }
@@ -364,7 +376,13 @@ export class MappingService {
364
376
  const candidates = rawCandidates.map(toResolutionCandidate);
365
377
  const limitedCandidates = limitResolutionCandidates(candidates, input.maxCandidates);
366
378
  const strictDescriptor = projectedDescriptor ?? descriptor;
367
- const strictCandidates = rawCandidates.filter((candidate) => candidate.descriptor === strictDescriptor);
379
+ const acceptedDescriptors = new Set([descriptor, strictDescriptor]);
380
+ const toObfuscatedPath = namespacePath(graph, sourceMapping, "obfuscated");
381
+ if (toObfuscatedPath) {
382
+ const obfuscatedProjection = this.projectMethodDescriptorToTarget(graph, toObfuscatedPath, descriptor);
383
+ acceptedDescriptors.add(obfuscatedProjection.descriptor);
384
+ }
385
+ const strictCandidates = rawCandidates.filter((candidate) => candidate.descriptor !== undefined && acceptedDescriptors.has(candidate.descriptor));
368
386
  if (strictCandidates.length === 1) {
369
387
  const resolved = toResolutionCandidate(strictCandidates[0]);
370
388
  return {
@@ -499,21 +517,18 @@ export class MappingService {
499
517
  warnings
500
518
  };
501
519
  }
502
- const baseRecords = collectTargetRecords(graph, baseMapping).filter((record) => {
503
- if (record.kind === "class") {
504
- return includeKinds.has("class") && record.symbol === baseClass.symbol;
505
- }
506
- if (record.owner !== baseClass.symbol) {
507
- return false;
520
+ const exactIndex = collectExactRecordIndex(graph, baseMapping);
521
+ const baseRecords = [];
522
+ if (includeKinds.has("class")) {
523
+ for (const record of lookupExactClassBySymbol(exactIndex, baseClass.symbol)) {
524
+ baseRecords.push(record);
508
525
  }
509
- if (record.kind === "field") {
510
- return includeKinds.has("field");
526
+ }
527
+ for (const record of lookupExactMembersByOwner(exactIndex, baseClass.symbol)) {
528
+ if (record.kind === "field" ? includeKinds.has("field") : includeKinds.has("method")) {
529
+ baseRecords.push(record);
511
530
  }
512
- return includeKinds.has("method");
513
- });
514
- const rows = [];
515
- let ambiguousRowCount = 0;
516
- const rowSeen = new Set();
531
+ }
517
532
  const rowKindOrder = {
518
533
  class: 0,
519
534
  field: 1,
@@ -530,12 +545,47 @@ export class MappingService {
530
545
  }
531
546
  return left.symbol.localeCompare(right.symbol);
532
547
  });
548
+ // Dedup up front using the base-only symbol key. The sort key and buildSymbolKey
549
+ // read only base-namespace fields, so this stable first-occurrence-wins order is
550
+ // fully determined WITHOUT any cross-namespace mapping. That lets rowCount /
551
+ // nextCursor / row order stay byte-identical while we map only the requested page.
552
+ const rowSeen = new Set();
553
+ const dedupedBase = [];
533
554
  for (const baseRecord of sortedBase) {
534
555
  const key = buildSymbolKey(baseRecord);
535
556
  if (rowSeen.has(key)) {
536
557
  continue;
537
558
  }
538
559
  rowSeen.add(key);
560
+ dedupedBase.push(baseRecord);
561
+ }
562
+ const rowCount = dedupedBase.length;
563
+ const rowLimit = clampRowLimit(input.maxRows);
564
+ if (input.maxRows != null && Number.isFinite(input.maxRows) && input.maxRows > 5000) {
565
+ warnings.push(`maxRows was clamped to 5000 from ${input.maxRows}.`);
566
+ }
567
+ // Offset cursor over the stable row order; the context key ties a cursor to
568
+ // this exact query so a stale cursor restarts from the first page.
569
+ const rowCursorContext = buildPageContextKey([
570
+ version,
571
+ className,
572
+ classNameMapping,
573
+ (input.includeKinds ?? []).join(","),
574
+ input.sourcePriority,
575
+ resolveGradleUserHomePath(input.gradleUserHome)
576
+ ]);
577
+ const { offset: rowOffset, cursorIgnored: rowCursorIgnored } = resolveCursorOffset(input.cursor, rowCursorContext);
578
+ const windowBase = rowLimit != null ? dedupedBase.slice(rowOffset, rowOffset + rowLimit) : dedupedBase.slice(rowOffset);
579
+ const consumedRows = rowOffset + windowBase.length;
580
+ const rowsTruncated = consumedRows < rowCount;
581
+ const nextCursor = rowsTruncated ? encodeOffsetCursor(consumedRows, rowCursorContext) : undefined;
582
+ // Map ONLY the requested window. Per-row mapping cost is therefore O(window),
583
+ // not O(rowCount). warnings/ambiguousRowCount are accordingly page-scoped on
584
+ // paginated calls (caveat B1) and byte-identical to the old full-set values when
585
+ // no maxRows/cursor narrows the window.
586
+ const rows = [];
587
+ let ambiguousRowCount = 0;
588
+ for (const baseRecord of windowBase) {
539
589
  let rowHadAmbiguity = false;
540
590
  const row = {
541
591
  kind: baseRecord.kind,
@@ -584,9 +634,6 @@ export class MappingService {
584
634
  ambiguousRowCount += 1;
585
635
  }
586
636
  }
587
- const rowCount = rows.length;
588
- const rowLimit = clampRowLimit(input.maxRows);
589
- const limitedRows = rowLimit != null && rowCount > rowLimit ? rows.slice(0, rowLimit) : rows;
590
637
  return {
591
638
  version,
592
639
  className,
@@ -597,9 +644,11 @@ export class MappingService {
597
644
  intermediary: classByMapping.intermediary?.symbol,
598
645
  yarn: classByMapping.yarn?.symbol
599
646
  },
600
- rows: limitedRows,
647
+ rows,
601
648
  rowCount,
602
- rowsTruncated: limitedRows.length < rowCount ? true : undefined,
649
+ rowsTruncated: rowsTruncated ? true : undefined,
650
+ ...(nextCursor ? { nextCursor } : {}),
651
+ ...(rowCursorIgnored ? { cursorIgnored: true } : {}),
603
652
  warnings,
604
653
  ambiguousRowCount: ambiguousRowCount > 0 ? ambiguousRowCount : undefined
605
654
  };
@@ -632,6 +681,11 @@ export class MappingService {
632
681
  sourcePriorityApplied: priority
633
682
  };
634
683
  const classNameMode = input.nameMode === "auto" ? "auto" : "fqcn";
684
+ // Normalize the effective signatureMode exactly once (mirrors findMapping) so an omitted
685
+ // signatureMode reaching the service — direct/internal callers, MCP resource handlers, and
686
+ // batch-symbol-exists entries — defaults to name-only instead of taking the strict
687
+ // descriptor-required path. Callers needing strict matching pass signatureMode: "exact".
688
+ const effectiveSignatureMode = input.signatureMode ?? "name-only";
635
689
  const normalizedQuery = input.kind === "class" && classNameMode === "auto"
636
690
  ? (() => {
637
691
  const owner = input.owner?.trim();
@@ -663,7 +717,9 @@ export class MappingService {
663
717
  };
664
718
  })()
665
719
  : (() => {
666
- const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, input.signatureMode);
720
+ const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, effectiveSignatureMode, {
721
+ allowShortClassName: input.kind === "class" && sourceMapping === "obfuscated"
722
+ });
667
723
  return {
668
724
  mode: "strict",
669
725
  queryRecord,
@@ -684,6 +740,7 @@ export class MappingService {
684
740
  warnings
685
741
  };
686
742
  }
743
+ const exactIndex = collectExactRecordIndex(graph, sourceMapping);
687
744
  const buildOutput = (querySymbol, matched, status) => {
688
745
  const candidates = matched.map((record) => toResolutionCandidate(toLookupCandidate(record)));
689
746
  const limitedCandidates = limitResolutionCandidates(candidates, input.maxCandidates);
@@ -705,11 +762,11 @@ export class MappingService {
705
762
  if (normalizedQuery.mode === "auto-class") {
706
763
  const autoClassName = normalizedQuery.className;
707
764
  if (autoClassName.includes(".")) {
708
- const matched = records.filter((record) => record.kind === "class" && record.symbol === autoClassName);
765
+ const matched = lookupExactClassBySymbol(exactIndex, autoClassName);
709
766
  const status = matched.length === 1 ? "resolved" : matched.length > 1 ? "ambiguous" : "not_found";
710
767
  return buildOutput(normalizedQuery.querySymbol, matched, status);
711
768
  }
712
- const matched = records.filter((record) => record.kind === "class" && record.name === autoClassName);
769
+ const matched = lookupExactClassBySimpleName(exactIndex, autoClassName);
713
770
  const status = matched.length === 1 ? "resolved" : matched.length > 1 ? "ambiguous" : "not_found";
714
771
  if (status === "ambiguous") {
715
772
  warnings.push(`Multiple class symbols matched short name "${autoClassName}". Provide fully-qualified class name.`);
@@ -718,18 +775,18 @@ export class MappingService {
718
775
  }
719
776
  const { queryRecord, querySymbol } = normalizedQuery;
720
777
  if (queryRecord.kind === "class") {
721
- const matched = records.filter((record) => record.kind === "class" && record.symbol === queryRecord.symbol);
778
+ const matched = lookupExactClassBySymbol(exactIndex, queryRecord.symbol);
722
779
  const status = matched.length === 1 ? "resolved" : matched.length > 1 ? "ambiguous" : "not_found";
723
780
  return buildOutput(querySymbol, matched, status);
724
781
  }
725
782
  if (queryRecord.kind === "field") {
726
- const matched = records.filter((record) => record.kind === "field" && record.owner === queryRecord.owner && record.name === queryRecord.name);
783
+ const matched = lookupExactMembers(exactIndex, "field", queryRecord.owner ?? "", queryRecord.name);
727
784
  const status = matched.length === 1 ? "resolved" : matched.length > 1 ? "ambiguous" : "not_found";
728
785
  return buildOutput(querySymbol, matched, status);
729
786
  }
730
- const methodCandidates = records.filter((record) => record.kind === "method" && record.owner === queryRecord.owner && record.name === queryRecord.name);
787
+ const methodCandidates = lookupExactMembers(exactIndex, "method", queryRecord.owner ?? "", queryRecord.name);
731
788
  // name-only mode: skip descriptor matching, resolve by owner+name
732
- if (input.signatureMode === "name-only") {
789
+ if (effectiveSignatureMode === "name-only") {
733
790
  const status = methodCandidates.length === 1 ? "resolved" : methodCandidates.length > 1 ? "ambiguous" : "not_found";
734
791
  if (status === "ambiguous") {
735
792
  // name-only discards any supplied descriptor, so telling the caller to "provide
@@ -754,18 +811,27 @@ export class MappingService {
754
811
  // and produce false negatives in the most common lookup shape. When no class references
755
812
  // exist at all (primitives-only descriptors such as `(I)V`) the projector marks
756
813
  // `hadClassReferences === false` and we simply reuse the original descriptor.
814
+ // Stored descriptors carry whatever coordinate the tiny column used: obfuscated
815
+ // for merged graphs, but intermediary for a standalone Fabric yarn tiny
816
+ // (`tiny 2 0 intermediary named`). The descriptor namespace is not recorded
817
+ // per record, so project the query descriptor along every reachable base
818
+ // namespace and accept a match in any of them rather than assuming obfuscated.
757
819
  const queryDescriptor = queryRecord.descriptor;
758
- let effectiveDescriptor = queryDescriptor;
759
- if (sourceMapping !== "obfuscated") {
760
- const projectionPath = namespacePath(graph, sourceMapping, "obfuscated");
761
- if (projectionPath) {
762
- const projection = this.projectMethodDescriptorToTarget(graph, projectionPath, queryDescriptor);
763
- if (projection.hadClassReferences) {
764
- effectiveDescriptor = projection.descriptor;
765
- }
820
+ const candidateDescriptors = new Set([queryDescriptor]);
821
+ for (const baseNamespace of ["obfuscated", "intermediary"]) {
822
+ if (sourceMapping === baseNamespace) {
823
+ continue;
824
+ }
825
+ const projectionPath = namespacePath(graph, sourceMapping, baseNamespace);
826
+ if (!projectionPath) {
827
+ continue;
828
+ }
829
+ const projection = this.projectMethodDescriptorToTarget(graph, projectionPath, queryDescriptor);
830
+ if (projection.hadClassReferences) {
831
+ candidateDescriptors.add(projection.descriptor);
766
832
  }
767
833
  }
768
- const descriptorMatched = methodCandidates.filter((record) => record.descriptor === effectiveDescriptor || record.descriptor === queryDescriptor);
834
+ const descriptorMatched = methodCandidates.filter((record) => record.descriptor != null && candidateDescriptors.has(record.descriptor));
769
835
  if (descriptorMatched.length === 1) {
770
836
  return buildOutput(querySymbol, descriptorMatched, "resolved");
771
837
  }
@@ -782,6 +848,9 @@ export class MappingService {
782
848
  if (sourceMapping === targetMapping) {
783
849
  return [record];
784
850
  }
851
+ // Count genuine cross-namespace path walks (the per-row cost getClassApiMatrix
852
+ // now scopes to the requested window); test seam via apiMatrixStats.
853
+ this.apiMatrixRowMaps += 1;
785
854
  const path = resolvedPath ?? namespacePath(graph, sourceMapping, targetMapping);
786
855
  if (!path) {
787
856
  return [];
@@ -886,22 +955,35 @@ export class MappingService {
886
955
  projectMethodDescriptorToTarget(graph, path, descriptor) {
887
956
  let hadClassReferences = false;
888
957
  let complete = true;
889
- const classProjectionCache = new Map();
958
+ // Class-to-class projection depends only on (graph, path, internalName) — not on
959
+ // the descriptor — so memoize it on the graph and share it across every member
960
+ // lookup on this graph instead of rebuilding a fresh cache per call. A `null`
961
+ // entry memoizes an unmapped/ambiguous class so a repeat reference stays
962
+ // `complete=false` without recomputing.
963
+ const projectionKey = path.join(">");
964
+ const classProjectionCache = graph.classProjectionCache;
890
965
  const projectedDescriptor = descriptor.replace(/L([^;]+);/g, (fullMatch, internalName) => {
891
966
  hadClassReferences = true;
892
- const cached = classProjectionCache.get(internalName);
893
- if (cached) {
967
+ const cacheKey = `${projectionKey}\0${internalName}`;
968
+ const cached = classProjectionCache.get(cacheKey);
969
+ if (cached !== undefined) {
970
+ if (cached === null) {
971
+ complete = false;
972
+ return fullMatch;
973
+ }
894
974
  return `L${cached};`;
895
975
  }
976
+ this.classProjectionComputes += 1;
896
977
  const projectedClassCandidates = this
897
978
  .mapCandidatesAlongPath(graph, path, createClassSymbolRecord(internalName.replace(/\//g, ".")))
898
979
  .filter((candidate) => candidate.kind === "class");
899
980
  if (projectedClassCandidates.length !== 1) {
900
981
  complete = false;
982
+ classProjectionCache.set(cacheKey, null);
901
983
  return fullMatch;
902
984
  }
903
985
  const projectedInternalName = projectedClassCandidates[0].symbol.replace(/\./g, "/");
904
- classProjectionCache.set(internalName, projectedInternalName);
986
+ classProjectionCache.set(cacheKey, projectedInternalName);
905
987
  return `L${projectedInternalName};`;
906
988
  });
907
989
  return {
@@ -1031,6 +1113,8 @@ export class MappingService {
1031
1113
  adjacency: new Map(),
1032
1114
  pathCache: new Map(),
1033
1115
  recordsByTarget: new Map(),
1116
+ exactRecordIndex: new Map(),
1117
+ classProjectionCache: new Map(),
1034
1118
  warnings: [
1035
1119
  `Version ${version} is unobfuscated; mapping graph is empty because the runtime already uses deobfuscated names.`
1036
1120
  ]
@@ -1044,6 +1128,8 @@ export class MappingService {
1044
1128
  adjacency: new Map(),
1045
1129
  pathCache: new Map(),
1046
1130
  recordsByTarget: new Map(),
1131
+ exactRecordIndex: new Map(),
1132
+ classProjectionCache: new Map(),
1047
1133
  warnings: []
1048
1134
  };
1049
1135
  const mojangLoad = await this.loadMojangPairs(version);
@@ -1209,25 +1295,68 @@ async function extractTinyFromJar(jarPath, outputPath) {
1209
1295
  return false;
1210
1296
  }
1211
1297
  await mkdir(dirname(outputPath), { recursive: true });
1212
- await writeFile(outputPath, tinyEntry.content, "utf8");
1298
+ const tempPath = `${outputPath}.${randomBytes(4).toString("hex")}.tmp`;
1299
+ await writeFile(tempPath, tinyEntry.content, "utf8");
1300
+ await rename(tempPath, outputPath);
1213
1301
  return true;
1214
1302
  }
1303
+ /**
1304
+ * How long a resolved yarn build coordinate is trusted before re-checking the
1305
+ * Fabric Maven metadata for a newer build. Within this window, resolving a yarn
1306
+ * tiny is zero-network (it reuses the recorded coordinate); after it expires the
1307
+ * next resolve does ONE metadata fetch. This fence keeps the metadata fetch off
1308
+ * the remap cache-hit fast path while still picking up new yarn builds.
1309
+ */
1310
+ const YARN_METADATA_TTL_MS = 24 * 60 * 60 * 1000;
1311
+ function yarnMetaPath(cacheDir, version) {
1312
+ return join(cacheDir, "mappings", "yarn", `${version}.meta.json`);
1313
+ }
1314
+ function yarnCoordinateTinyPath(cacheDir, coordinate) {
1315
+ return join(cacheDir, "mappings", "yarn", `${coordinate}.tiny`);
1316
+ }
1317
+ function readYarnCoordinateMeta(metaPath) {
1318
+ if (!existsSync(metaPath)) {
1319
+ return undefined;
1320
+ }
1321
+ try {
1322
+ const parsed = JSON.parse(readFileSync(metaPath, "utf8"));
1323
+ if (typeof parsed.coordinate === "string" && typeof parsed.fetchedAt === "number") {
1324
+ return { coordinate: parsed.coordinate, fetchedAt: parsed.fetchedAt };
1325
+ }
1326
+ }
1327
+ catch {
1328
+ // Corrupt marker -> treat as absent and re-resolve.
1329
+ }
1330
+ return undefined;
1331
+ }
1332
+ function writeYarnCoordinateMeta(metaPath, meta) {
1333
+ mkdirSync(dirname(metaPath), { recursive: true });
1334
+ writeFileSync(metaPath, JSON.stringify(meta));
1335
+ }
1215
1336
  /**
1216
1337
  * Resolve and cache a Tiny v2 mapping file for the given Minecraft version.
1217
1338
  *
1339
+ * Intermediary is version-keyed (immutable per release). Yarn is coordinate-keyed:
1340
+ * Fabric publishes incrementing yarn builds for the same MC version, so the tiny
1341
+ * is cached at `mappings/yarn/${coordinate}.tiny` and the newest coordinate is
1342
+ * re-checked once per {@link YARN_METADATA_TTL_MS}. The resolved yarn coordinate
1343
+ * is returned so callers can key their own caches on the actual mappings used.
1344
+ *
1218
1345
  * @param version - Minecraft version (e.g. "1.20.4")
1219
1346
  * @param mapping - "intermediary" or "yarn"
1220
1347
  * @param cacheDir - The application cache directory
1221
1348
  * @param fetchFn - Optional fetch implementation for testing
1222
- * @returns Path to the extracted Tiny v2 file
1349
+ * @param options - forceRefresh bypasses the yarn metadata TTL; now overrides the clock (testing)
1350
+ * @returns The extracted Tiny v2 path and, for yarn, the resolved build coordinate
1223
1351
  */
1224
- export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn) {
1225
- const cachedTiny = join(cacheDir, "mappings", `${version}-${mapping}.tiny`);
1226
- if (existsSync(cachedTiny)) {
1227
- return cachedTiny;
1228
- }
1352
+ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn, options) {
1229
1353
  const effectiveFetch = fetchFn ?? globalThis.fetch;
1230
1354
  if (mapping === "intermediary") {
1355
+ // Intermediary is immutable per MC release: version-keyed cache is correct.
1356
+ const cachedTiny = join(cacheDir, "mappings", `${version}-intermediary.tiny`);
1357
+ if (existsSync(cachedTiny)) {
1358
+ return { path: cachedTiny };
1359
+ }
1231
1360
  const url = `${FABRIC_MAVEN}/net/fabricmc/intermediary/${version}/intermediary-${version}-v2.jar`;
1232
1361
  const jarDest = defaultDownloadPath(cacheDir, url);
1233
1362
  const downloaded = await downloadToCache(url, jarDest, {
@@ -1250,11 +1379,30 @@ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn
1250
1379
  details: { version, jarPath: downloaded.path }
1251
1380
  });
1252
1381
  }
1253
- return cachedTiny;
1382
+ return { path: cachedTiny };
1383
+ }
1384
+ // yarn: coordinate-keyed + metadata-TTL fenced.
1385
+ const nowFn = options?.now ?? Date.now;
1386
+ const metaPath = yarnMetaPath(cacheDir, version);
1387
+ const existingMeta = readYarnCoordinateMeta(metaPath);
1388
+ // Within-TTL fast path: reuse the recorded coordinate with zero network.
1389
+ if (!options?.forceRefresh && existingMeta && nowFn() - existingMeta.fetchedAt < YARN_METADATA_TTL_MS) {
1390
+ const cachedCoordTiny = yarnCoordinateTinyPath(cacheDir, existingMeta.coordinate);
1391
+ if (existsSync(cachedCoordTiny)) {
1392
+ return { path: cachedCoordTiny, coordinate: existingMeta.coordinate };
1393
+ }
1254
1394
  }
1255
- // yarn
1256
1395
  const yarnCoordinates = await fetchYarnCoordinatesStandalone(version, effectiveFetch);
1396
+ // Network fallback: the metadata fetch failed/empty (fetchYarnCoordinatesStandalone
1397
+ // swallows errors -> []). Serve the last-known-good coordinate rather than failing,
1398
+ // so a transient Fabric Maven outage after TTL expiry does not break cache hits.
1257
1399
  if (yarnCoordinates.length === 0) {
1400
+ if (existingMeta) {
1401
+ const cachedCoordTiny = yarnCoordinateTinyPath(cacheDir, existingMeta.coordinate);
1402
+ if (existsSync(cachedCoordTiny)) {
1403
+ return { path: cachedCoordTiny, coordinate: existingMeta.coordinate };
1404
+ }
1405
+ }
1258
1406
  throw createError({
1259
1407
  code: ERROR_CODES.MAPPING_UNAVAILABLE,
1260
1408
  message: `No yarn builds found for Minecraft ${version}.`,
@@ -1262,6 +1410,12 @@ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn
1262
1410
  });
1263
1411
  }
1264
1412
  for (const coordinate of yarnCoordinates) {
1413
+ const coordTiny = yarnCoordinateTinyPath(cacheDir, coordinate);
1414
+ if (existsSync(coordTiny)) {
1415
+ // Already have this build's tiny; just refresh the TTL marker.
1416
+ writeYarnCoordinateMeta(metaPath, { coordinate, fetchedAt: nowFn() });
1417
+ return { path: coordTiny, coordinate };
1418
+ }
1265
1419
  const url = `${FABRIC_MAVEN}/net/fabricmc/yarn/${coordinate}/yarn-${coordinate}-v2.jar`;
1266
1420
  const jarDest = defaultDownloadPath(cacheDir, url);
1267
1421
  const downloaded = await downloadToCache(url, jarDest, {
@@ -1272,9 +1426,12 @@ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn
1272
1426
  if (!downloaded.ok || !downloaded.path) {
1273
1427
  continue;
1274
1428
  }
1275
- const extracted = await extractTinyFromJar(downloaded.path, cachedTiny);
1429
+ const extracted = await extractTinyFromJar(downloaded.path, coordTiny);
1276
1430
  if (extracted) {
1277
- return cachedTiny;
1431
+ // Ordering matters: the tiny exists before the marker is written, so a
1432
+ // crash never leaves a TTL-valid marker pointing at a missing tiny.
1433
+ writeYarnCoordinateMeta(metaPath, { coordinate, fetchedAt: nowFn() });
1434
+ return { path: coordTiny, coordinate };
1278
1435
  }
1279
1436
  }
1280
1437
  throw createError({
@@ -9,5 +9,6 @@ export declare function objectResource(uri: string, data: Record<string, unknown
9
9
  export declare function errorResource(uri: string, error: string | {
10
10
  message: string;
11
11
  code?: ErrorCode;
12
+ details?: unknown;
12
13
  }): ReadResourceResult;
13
14
  export {};
@@ -1,4 +1,6 @@
1
1
  import { ERROR_CODES } from "./errors.js";
2
+ import { retryClassForErrorCode, issueOriginForErrorCode, extractAllowlistedContext } from "./error-mapping.js";
3
+ import { toHints, extractValidatedSuggestionAndExamples, extractFieldErrorsFromDetails } from "./tool-guidance.js";
2
4
  export function objectResult(data, options = {}) {
3
5
  return {
4
6
  content: [{ type: "text", text: JSON.stringify(data) }],
@@ -42,8 +44,18 @@ function statusForResourceErrorCode(code) {
42
44
  return 500;
43
45
  }
44
46
  export function errorResource(uri, error) {
45
- const detail = typeof error === "string" ? error : error.message;
46
- const code = typeof error === "string" ? ERROR_CODES.INVALID_INPUT : error.code ?? ERROR_CODES.INTERNAL;
47
+ const isStr = typeof error === "string";
48
+ const detail = isStr ? error : error.message;
49
+ const code = isStr ? ERROR_CODES.INVALID_INPUT : error.code ?? ERROR_CODES.INTERNAL;
50
+ // Resource reads carry the same AppError as the equivalent tool call, so they
51
+ // get the same recovery metadata. Classifiers are always present; the rest is
52
+ // extracted from the AppError details (reusing the tool-error helpers as-is so
53
+ // the suggestedCall is validated through the single buildSuggestedCall gate).
54
+ const details = isStr ? undefined : error.details;
55
+ const hints = toHints(details);
56
+ const { suggestedCall, exampleCalls } = extractValidatedSuggestionAndExamples(details);
57
+ const fieldErrors = extractFieldErrorsFromDetails(details);
58
+ const context = extractAllowlistedContext(details);
47
59
  return {
48
60
  contents: [
49
61
  {
@@ -51,12 +63,22 @@ export function errorResource(uri, error) {
51
63
  mimeType: "application/json",
52
64
  text: JSON.stringify({
53
65
  error: {
66
+ // Keep the generic resource type/title (asserted by tests and
67
+ // intentionally distinct from the per-code tool ProblemDetails) so
68
+ // the two access paths stay distinguishable.
54
69
  type: "https://minecraft-modding-mcp.dev/problems/resource",
55
70
  title: "Resource read failed",
56
71
  detail,
57
72
  status: statusForResourceErrorCode(code),
58
73
  code,
59
- instance: uri
74
+ instance: uri,
75
+ retryClass: retryClassForErrorCode(code),
76
+ issueOrigin: issueOriginForErrorCode(code),
77
+ ...(fieldErrors ? { fieldErrors } : {}),
78
+ ...(hints ? { hints } : {}),
79
+ ...(suggestedCall ? { suggestedCall } : {}),
80
+ ...(exampleCalls ? { exampleCalls } : {}),
81
+ ...(context ? { context } : {})
60
82
  },
61
83
  meta: { uri }
62
84
  })
@@ -1,7 +1,7 @@
1
1
  import { createError, ERROR_CODES } from "./errors.js";
2
2
  import { loadConfig } from "./config.js";
3
3
  import { artifactSignatureFromFile, normalizeJarPath } from "./path-resolver.js";
4
- import { readJarEntryAsBuffer } from "./source-jar-reader.js";
4
+ import { createJarEntryReader } from "./source-jar-reader.js";
5
5
  const CLASSFILE_MAGIC = 0xcafebabe;
6
6
  const MAX_INHERITANCE_DEPTH = 64;
7
7
  const ACC_PUBLIC = 0x0001;
@@ -452,18 +452,28 @@ export class MinecraftExplorerService {
452
452
  };
453
453
  }
454
454
  const classEntryPath = `${toInternalName(fqn)}.class`;
455
- let classBuffer;
455
+ // Open the jar ONCE for this call; every hierarchy class is served from the
456
+ // same handle (see reader.close() after hierarchy construction below).
457
+ const reader = await createJarEntryReader(jarPath);
458
+ let parsed;
456
459
  try {
457
- classBuffer = await readJarEntryAsBuffer(jarPath, classEntryPath);
460
+ let classBuffer;
461
+ try {
462
+ classBuffer = await reader.getEntryBuffer(classEntryPath);
463
+ }
464
+ catch {
465
+ throw createError({
466
+ code: ERROR_CODES.CLASS_NOT_FOUND,
467
+ message: `Class "${fqn}" was not found in "${jarPath}".`,
468
+ details: { fqn, jarPath, classEntryPath }
469
+ });
470
+ }
471
+ parsed = parseClassFile(classBuffer);
458
472
  }
459
- catch {
460
- throw createError({
461
- code: ERROR_CODES.CLASS_NOT_FOUND,
462
- message: `Class "${fqn}" was not found in "${jarPath}".`,
463
- details: { fqn, jarPath, classEntryPath }
464
- });
473
+ catch (error) {
474
+ reader.close();
475
+ throw error;
465
476
  }
466
- const parsed = parseClassFile(classBuffer);
467
477
  const parsedClassCache = new Map([[parsed.internalName, parsed]]);
468
478
  const warnings = [];
469
479
  const warnMissingInheritedClass = (internalName, relation) => {
@@ -476,7 +486,7 @@ export class MinecraftExplorerService {
476
486
  }
477
487
  const classPath = `${internalName}.class`;
478
488
  try {
479
- const classBytes = await readJarEntryAsBuffer(jarPath, classPath);
489
+ const classBytes = await reader.getEntryBuffer(classPath);
480
490
  const parsedClass = parseClassFile(classBytes);
481
491
  parsedClassCache.set(parsedClass.internalName, parsedClass);
482
492
  return parsedClass;
@@ -540,6 +550,9 @@ export class MinecraftExplorerService {
540
550
  }
541
551
  hierarchyClasses.push(...interfaceClasses);
542
552
  }
553
+ // All jar reads are done; release the file descriptor before member projection
554
+ // (which performs no IO but may throw on malformed descriptors).
555
+ reader.close();
543
556
  const toSignatureMember = (ownerFqn, ownerSimpleClassName, member, category) => {
544
557
  if (category === "field") {
545
558
  const parsedField = parseFieldType(member.descriptor, 0, { allowVoid: false });