@adhisang/minecraft-modding-mcp 4.2.1 → 5.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 (114) hide show
  1. package/CHANGELOG.md +33 -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/build-suggested-call.js +31 -10
  6. package/dist/cache-registry.d.ts +8 -0
  7. package/dist/cache-registry.js +15 -1
  8. package/dist/config.d.ts +1 -0
  9. package/dist/config.js +4 -0
  10. package/dist/entry-tools/analyze-mod-service.d.ts +24 -24
  11. package/dist/entry-tools/analyze-symbol-service.d.ts +57 -22
  12. package/dist/entry-tools/analyze-symbol-service.js +124 -28
  13. package/dist/entry-tools/batch-class-members-service.d.ts +3 -1
  14. package/dist/entry-tools/batch-class-members-service.js +6 -6
  15. package/dist/entry-tools/batch-class-source-service.d.ts +3 -1
  16. package/dist/entry-tools/batch-class-source-service.js +5 -6
  17. package/dist/entry-tools/batch-mappings-service.d.ts +3 -1
  18. package/dist/entry-tools/batch-mappings-service.js +4 -5
  19. package/dist/entry-tools/batch-symbol-exists-service.d.ts +3 -1
  20. package/dist/entry-tools/batch-symbol-exists-service.js +4 -5
  21. package/dist/entry-tools/compare-minecraft-service.d.ts +10 -10
  22. package/dist/entry-tools/entry-tool-schema.d.ts +1 -1
  23. package/dist/entry-tools/inspect-minecraft/handlers/artifact.js +1 -3
  24. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +2 -2
  25. package/dist/entry-tools/inspect-minecraft/handlers/class-overview.js +0 -5
  26. package/dist/entry-tools/inspect-minecraft/handlers/class-source.js +0 -1
  27. package/dist/entry-tools/inspect-minecraft/handlers/file.js +0 -1
  28. package/dist/entry-tools/inspect-minecraft/handlers/list-files.js +0 -1
  29. package/dist/entry-tools/inspect-minecraft/handlers/search.d.ts +8 -0
  30. package/dist/entry-tools/inspect-minecraft/handlers/search.js +13 -2
  31. package/dist/entry-tools/inspect-minecraft/handlers/versions.d.ts +1 -1
  32. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +1 -1
  33. package/dist/entry-tools/inspect-minecraft/internal.d.ts +90 -89
  34. package/dist/entry-tools/inspect-minecraft/internal.js +30 -14
  35. package/dist/entry-tools/inspect-minecraft-service.d.ts +218 -218
  36. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  37. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  38. package/dist/entry-tools/response-contract.d.ts +1 -1
  39. package/dist/entry-tools/response-contract.js +1 -0
  40. package/dist/entry-tools/validate-project/cases/mixin.js +2 -1
  41. package/dist/entry-tools/validate-project/cases/project-summary.js +5 -3
  42. package/dist/entry-tools/validate-project-service.d.ts +42 -36
  43. package/dist/entry-tools/validate-project-service.js +3 -1
  44. package/dist/entry-tools/verify-mixin-target-service.d.ts +30 -0
  45. package/dist/entry-tools/verify-mixin-target-service.js +113 -22
  46. package/dist/error-mapping.d.ts +51 -0
  47. package/dist/error-mapping.js +157 -2
  48. package/dist/index.js +138 -71
  49. package/dist/mapping/internal-types.d.ts +7 -0
  50. package/dist/mapping/lookup.d.ts +1 -1
  51. package/dist/mapping/lookup.js +7 -1
  52. package/dist/mapping/types.d.ts +6 -0
  53. package/dist/mapping-service.d.ts +26 -2
  54. package/dist/mapping-service.js +178 -36
  55. package/dist/mcp-helpers.d.ts +1 -0
  56. package/dist/mcp-helpers.js +25 -3
  57. package/dist/minecraft-explorer-service.js +24 -11
  58. package/dist/mixin/access-validators.js +33 -1
  59. package/dist/mixin/annotation-validators.js +53 -7
  60. package/dist/mixin/parsed-validator.js +2 -2
  61. package/dist/mixin-parser.js +48 -4
  62. package/dist/mod-decompile-service.js +2 -1
  63. package/dist/mod-remap-service.d.ts +20 -1
  64. package/dist/mod-remap-service.js +95 -40
  65. package/dist/nbt/java-nbt-codec.js +6 -0
  66. package/dist/observability.d.ts +6 -0
  67. package/dist/observability.js +10 -0
  68. package/dist/page-cursor.d.ts +17 -0
  69. package/dist/page-cursor.js +0 -0
  70. package/dist/registry-service.js +25 -1
  71. package/dist/resources.js +45 -9
  72. package/dist/response-utils.d.ts +29 -4
  73. package/dist/response-utils.js +95 -5
  74. package/dist/source/access-validate.js +12 -2
  75. package/dist/source/artifact-resolver.js +1 -1
  76. package/dist/source/class-source/members-builder.d.ts +44 -1
  77. package/dist/source/class-source/members-builder.js +51 -4
  78. package/dist/source/class-source/snippet-builder.d.ts +13 -0
  79. package/dist/source/class-source/snippet-builder.js +48 -4
  80. package/dist/source/class-source-helpers.js +67 -11
  81. package/dist/source/class-source.js +97 -20
  82. package/dist/source/indexer.js +5 -2
  83. package/dist/source/search.d.ts +1 -1
  84. package/dist/source/search.js +94 -22
  85. package/dist/source/validate-mixin.d.ts +2 -1
  86. package/dist/source/validate-mixin.js +45 -4
  87. package/dist/source/workspace-target.js +15 -9
  88. package/dist/source-jar-reader.d.ts +54 -1
  89. package/dist/source-jar-reader.js +121 -10
  90. package/dist/source-service.d.ts +28 -7
  91. package/dist/storage/files-repo.d.ts +12 -0
  92. package/dist/storage/files-repo.js +24 -0
  93. package/dist/storage/symbols-repo.d.ts +6 -0
  94. package/dist/storage/symbols-repo.js +6 -0
  95. package/dist/symbols/symbol-extractor.js +35 -6
  96. package/dist/text-truncate.d.ts +7 -0
  97. package/dist/text-truncate.js +21 -0
  98. package/dist/tiny-remapper-resolver.js +10 -0
  99. package/dist/tool-contract-manifest.js +2 -2
  100. package/dist/tool-execution-gate.js +12 -1
  101. package/dist/tool-guidance.d.ts +4 -1
  102. package/dist/tool-guidance.js +37 -12
  103. package/dist/tool-schemas.d.ts +559 -494
  104. package/dist/tool-schemas.js +99 -63
  105. package/dist/types.d.ts +1 -0
  106. package/dist/version-diff-service.js +5 -0
  107. package/dist/version-service.d.ts +3 -0
  108. package/dist/version-service.js +18 -2
  109. package/dist/warning-details.d.ts +18 -0
  110. package/dist/warning-details.js +63 -0
  111. package/docs/README-ja.md +2 -2
  112. package/docs/examples.md +26 -2
  113. package/docs/tool-reference.md +22 -17
  114. package/package.json +1 -1
@@ -14,11 +14,21 @@ export declare class MappingService {
14
14
  private static readonly RESOLUTION_CACHE_TTL_MS;
15
15
  private resolutionCacheHits;
16
16
  private resolutionCacheMisses;
17
+ private classProjectionComputes;
18
+ private apiMatrixRowMaps;
17
19
  get resolutionCacheStats(): {
18
20
  hits: number;
19
21
  misses: number;
20
22
  size: number;
21
23
  };
24
+ /** Test seam: counts genuine class-projection computes (cache misses). */
25
+ get classProjectionStats(): {
26
+ computes: number;
27
+ };
28
+ /** Test seam: counts genuine per-row cross-namespace path walks in getClassApiMatrix. */
29
+ get apiMatrixStats(): {
30
+ rowMaps: number;
31
+ };
22
32
  constructor(config: Config, versionService?: VersionMappingsResolver, fetchFn?: typeof fetch);
23
33
  findMapping(input: FindMappingInput): Promise<FindMappingOutput>;
24
34
  ensureMappingAvailable(input: EnsureMappingAvailableInput): Promise<EnsureMappingAvailableOutput>;
@@ -60,13 +70,27 @@ export declare class MappingService {
60
70
  private buildResolutionCacheKey;
61
71
  private trimResolutionCache;
62
72
  }
73
+ export type ResolvedTinyMapping = {
74
+ path: string;
75
+ coordinate?: string;
76
+ };
63
77
  /**
64
78
  * Resolve and cache a Tiny v2 mapping file for the given Minecraft version.
65
79
  *
80
+ * Intermediary is version-keyed (immutable per release). Yarn is coordinate-keyed:
81
+ * Fabric publishes incrementing yarn builds for the same MC version, so the tiny
82
+ * is cached at `mappings/yarn/${coordinate}.tiny` and the newest coordinate is
83
+ * re-checked once per {@link YARN_METADATA_TTL_MS}. The resolved yarn coordinate
84
+ * is returned so callers can key their own caches on the actual mappings used.
85
+ *
66
86
  * @param version - Minecraft version (e.g. "1.20.4")
67
87
  * @param mapping - "intermediary" or "yarn"
68
88
  * @param cacheDir - The application cache directory
69
89
  * @param fetchFn - Optional fetch implementation for testing
70
- * @returns Path to the extracted Tiny v2 file
90
+ * @param options - forceRefresh bypasses the yarn metadata TTL; now overrides the clock (testing)
91
+ * @returns The extracted Tiny v2 path and, for yarn, the resolved build coordinate
71
92
  */
72
- export declare function resolveTinyMappingFile(version: string, mapping: "intermediary" | "yarn", cacheDir: string, fetchFn?: typeof fetch): Promise<string>;
93
+ export declare function resolveTinyMappingFile(version: string, mapping: "intermediary" | "yarn", cacheDir: string, fetchFn?: typeof fetch, options?: {
94
+ forceRefresh?: boolean;
95
+ now?: () => number;
96
+ }): Promise<ResolvedTinyMapping>;
@@ -1,8 +1,9 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { buildSuggestedCall } from "./build-suggested-call.js";
5
5
  import { createError, ERROR_CODES } from "./errors.js";
6
+ import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "./page-cursor.js";
6
7
  import { resolveGradleUserHomePath } from "./gradle-paths.js";
7
8
  import { defaultDownloadPath, downloadToCache } from "./repo-downloader.js";
8
9
  import { collectMatchedJarEntriesAsUtf8 } from "./source-jar-reader.js";
@@ -32,6 +33,8 @@ export class MappingService {
32
33
  static RESOLUTION_CACHE_TTL_MS = 5 * 60 * 1000;
33
34
  resolutionCacheHits = 0;
34
35
  resolutionCacheMisses = 0;
36
+ classProjectionComputes = 0;
37
+ apiMatrixRowMaps = 0;
35
38
  get resolutionCacheStats() {
36
39
  return {
37
40
  hits: this.resolutionCacheHits,
@@ -39,6 +42,14 @@ export class MappingService {
39
42
  size: this.resolutionCache.size
40
43
  };
41
44
  }
45
+ /** Test seam: counts genuine class-projection computes (cache misses). */
46
+ get classProjectionStats() {
47
+ return { computes: this.classProjectionComputes };
48
+ }
49
+ /** Test seam: counts genuine per-row cross-namespace path walks in getClassApiMatrix. */
50
+ get apiMatrixStats() {
51
+ return { rowMaps: this.apiMatrixRowMaps };
52
+ }
42
53
  constructor(config, versionService = new VersionService(config), fetchFn = globalThis.fetch) {
43
54
  this.config = config;
44
55
  this.versionService = versionService;
@@ -65,7 +76,7 @@ export class MappingService {
65
76
  // explicitly.
66
77
  const effectiveSignatureMode = input.signatureMode ?? "name-only";
67
78
  const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, effectiveSignatureMode, {
68
- allowShortClassName: input.kind === "class" && input.sourceMapping === "obfuscated"
79
+ allowShortClassName: input.kind === "class" && (input.sourceMapping === "obfuscated" || input.nameMode === "auto")
69
80
  });
70
81
  const cacheKey = this.buildResolutionCacheKey(version, input, querySymbol, effectiveSignatureMode);
71
82
  const cached = this.resolutionCache.get(cacheKey);
@@ -176,7 +187,7 @@ export class MappingService {
176
187
  }
177
188
  rawCandidates = rawCandidates.filter((candidate) => candidate.descriptor !== undefined && acceptedDescriptors.has(candidate.descriptor));
178
189
  }
179
- const disambiguatedCandidates = applyDisambiguationHints(rawCandidates, input.disambiguation);
190
+ const disambiguatedCandidates = applyDisambiguationHints(rawCandidates, input.disambiguation, warnings);
180
191
  if (rawCandidates.length > disambiguatedCandidates.length) {
181
192
  warnings.push(`Disambiguation hints narrowed candidates from ${rawCandidates.length} to ${disambiguatedCandidates.length}.`);
182
193
  }
@@ -511,9 +522,6 @@ export class MappingService {
511
522
  }
512
523
  return includeKinds.has("method");
513
524
  });
514
- const rows = [];
515
- let ambiguousRowCount = 0;
516
- const rowSeen = new Set();
517
525
  const rowKindOrder = {
518
526
  class: 0,
519
527
  field: 1,
@@ -530,12 +538,46 @@ export class MappingService {
530
538
  }
531
539
  return left.symbol.localeCompare(right.symbol);
532
540
  });
541
+ // Dedup up front using the base-only symbol key. The sort key and buildSymbolKey
542
+ // read only base-namespace fields, so this stable first-occurrence-wins order is
543
+ // fully determined WITHOUT any cross-namespace mapping. That lets rowCount /
544
+ // nextCursor / row order stay byte-identical while we map only the requested page.
545
+ const rowSeen = new Set();
546
+ const dedupedBase = [];
533
547
  for (const baseRecord of sortedBase) {
534
548
  const key = buildSymbolKey(baseRecord);
535
549
  if (rowSeen.has(key)) {
536
550
  continue;
537
551
  }
538
552
  rowSeen.add(key);
553
+ dedupedBase.push(baseRecord);
554
+ }
555
+ const rowCount = dedupedBase.length;
556
+ const rowLimit = clampRowLimit(input.maxRows);
557
+ if (input.maxRows != null && Number.isFinite(input.maxRows) && input.maxRows > 5000) {
558
+ warnings.push(`maxRows was clamped to 5000 from ${input.maxRows}.`);
559
+ }
560
+ // Offset cursor over the stable row order; the context key ties a cursor to
561
+ // this exact query so a stale cursor restarts from the first page.
562
+ const rowCursorContext = buildPageContextKey([
563
+ version,
564
+ className,
565
+ classNameMapping,
566
+ (input.includeKinds ?? []).join(","),
567
+ input.sourcePriority
568
+ ]);
569
+ const { offset: rowOffset, cursorIgnored: rowCursorIgnored } = resolveCursorOffset(input.cursor, rowCursorContext);
570
+ const windowBase = rowLimit != null ? dedupedBase.slice(rowOffset, rowOffset + rowLimit) : dedupedBase.slice(rowOffset);
571
+ const consumedRows = rowOffset + windowBase.length;
572
+ const rowsTruncated = consumedRows < rowCount;
573
+ const nextCursor = rowsTruncated ? encodeOffsetCursor(consumedRows, rowCursorContext) : undefined;
574
+ // Map ONLY the requested window. Per-row mapping cost is therefore O(window),
575
+ // not O(rowCount). warnings/ambiguousRowCount are accordingly page-scoped on
576
+ // paginated calls (caveat B1) and byte-identical to the old full-set values when
577
+ // no maxRows/cursor narrows the window.
578
+ const rows = [];
579
+ let ambiguousRowCount = 0;
580
+ for (const baseRecord of windowBase) {
539
581
  let rowHadAmbiguity = false;
540
582
  const row = {
541
583
  kind: baseRecord.kind,
@@ -584,9 +626,6 @@ export class MappingService {
584
626
  ambiguousRowCount += 1;
585
627
  }
586
628
  }
587
- const rowCount = rows.length;
588
- const rowLimit = clampRowLimit(input.maxRows);
589
- const limitedRows = rowLimit != null && rowCount > rowLimit ? rows.slice(0, rowLimit) : rows;
590
629
  return {
591
630
  version,
592
631
  className,
@@ -597,9 +636,11 @@ export class MappingService {
597
636
  intermediary: classByMapping.intermediary?.symbol,
598
637
  yarn: classByMapping.yarn?.symbol
599
638
  },
600
- rows: limitedRows,
639
+ rows,
601
640
  rowCount,
602
- rowsTruncated: limitedRows.length < rowCount ? true : undefined,
641
+ rowsTruncated: rowsTruncated ? true : undefined,
642
+ ...(nextCursor ? { nextCursor } : {}),
643
+ ...(rowCursorIgnored ? { cursorIgnored: true } : {}),
603
644
  warnings,
604
645
  ambiguousRowCount: ambiguousRowCount > 0 ? ambiguousRowCount : undefined
605
646
  };
@@ -632,6 +673,11 @@ export class MappingService {
632
673
  sourcePriorityApplied: priority
633
674
  };
634
675
  const classNameMode = input.nameMode === "auto" ? "auto" : "fqcn";
676
+ // Normalize the effective signatureMode exactly once (mirrors findMapping) so an omitted
677
+ // signatureMode reaching the service — direct/internal callers, MCP resource handlers, and
678
+ // batch-symbol-exists entries — defaults to name-only instead of taking the strict
679
+ // descriptor-required path. Callers needing strict matching pass signatureMode: "exact".
680
+ const effectiveSignatureMode = input.signatureMode ?? "name-only";
635
681
  const normalizedQuery = input.kind === "class" && classNameMode === "auto"
636
682
  ? (() => {
637
683
  const owner = input.owner?.trim();
@@ -663,7 +709,7 @@ export class MappingService {
663
709
  };
664
710
  })()
665
711
  : (() => {
666
- const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, input.signatureMode);
712
+ const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, effectiveSignatureMode);
667
713
  return {
668
714
  mode: "strict",
669
715
  queryRecord,
@@ -729,7 +775,7 @@ export class MappingService {
729
775
  }
730
776
  const methodCandidates = records.filter((record) => record.kind === "method" && record.owner === queryRecord.owner && record.name === queryRecord.name);
731
777
  // name-only mode: skip descriptor matching, resolve by owner+name
732
- if (input.signatureMode === "name-only") {
778
+ if (effectiveSignatureMode === "name-only") {
733
779
  const status = methodCandidates.length === 1 ? "resolved" : methodCandidates.length > 1 ? "ambiguous" : "not_found";
734
780
  if (status === "ambiguous") {
735
781
  // name-only discards any supplied descriptor, so telling the caller to "provide
@@ -754,18 +800,27 @@ export class MappingService {
754
800
  // and produce false negatives in the most common lookup shape. When no class references
755
801
  // exist at all (primitives-only descriptors such as `(I)V`) the projector marks
756
802
  // `hadClassReferences === false` and we simply reuse the original descriptor.
803
+ // Stored descriptors carry whatever coordinate the tiny column used: obfuscated
804
+ // for merged graphs, but intermediary for a standalone Fabric yarn tiny
805
+ // (`tiny 2 0 intermediary named`). The descriptor namespace is not recorded
806
+ // per record, so project the query descriptor along every reachable base
807
+ // namespace and accept a match in any of them rather than assuming obfuscated.
757
808
  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
- }
809
+ const candidateDescriptors = new Set([queryDescriptor]);
810
+ for (const baseNamespace of ["obfuscated", "intermediary"]) {
811
+ if (sourceMapping === baseNamespace) {
812
+ continue;
813
+ }
814
+ const projectionPath = namespacePath(graph, sourceMapping, baseNamespace);
815
+ if (!projectionPath) {
816
+ continue;
817
+ }
818
+ const projection = this.projectMethodDescriptorToTarget(graph, projectionPath, queryDescriptor);
819
+ if (projection.hadClassReferences) {
820
+ candidateDescriptors.add(projection.descriptor);
766
821
  }
767
822
  }
768
- const descriptorMatched = methodCandidates.filter((record) => record.descriptor === effectiveDescriptor || record.descriptor === queryDescriptor);
823
+ const descriptorMatched = methodCandidates.filter((record) => record.descriptor != null && candidateDescriptors.has(record.descriptor));
769
824
  if (descriptorMatched.length === 1) {
770
825
  return buildOutput(querySymbol, descriptorMatched, "resolved");
771
826
  }
@@ -782,6 +837,9 @@ export class MappingService {
782
837
  if (sourceMapping === targetMapping) {
783
838
  return [record];
784
839
  }
840
+ // Count genuine cross-namespace path walks (the per-row cost getClassApiMatrix
841
+ // now scopes to the requested window); test seam via apiMatrixStats.
842
+ this.apiMatrixRowMaps += 1;
785
843
  const path = resolvedPath ?? namespacePath(graph, sourceMapping, targetMapping);
786
844
  if (!path) {
787
845
  return [];
@@ -886,22 +944,35 @@ export class MappingService {
886
944
  projectMethodDescriptorToTarget(graph, path, descriptor) {
887
945
  let hadClassReferences = false;
888
946
  let complete = true;
889
- const classProjectionCache = new Map();
947
+ // Class-to-class projection depends only on (graph, path, internalName) — not on
948
+ // the descriptor — so memoize it on the graph and share it across every member
949
+ // lookup on this graph instead of rebuilding a fresh cache per call. A `null`
950
+ // entry memoizes an unmapped/ambiguous class so a repeat reference stays
951
+ // `complete=false` without recomputing.
952
+ const projectionKey = path.join(">");
953
+ const classProjectionCache = graph.classProjectionCache;
890
954
  const projectedDescriptor = descriptor.replace(/L([^;]+);/g, (fullMatch, internalName) => {
891
955
  hadClassReferences = true;
892
- const cached = classProjectionCache.get(internalName);
893
- if (cached) {
956
+ const cacheKey = `${projectionKey}\0${internalName}`;
957
+ const cached = classProjectionCache.get(cacheKey);
958
+ if (cached !== undefined) {
959
+ if (cached === null) {
960
+ complete = false;
961
+ return fullMatch;
962
+ }
894
963
  return `L${cached};`;
895
964
  }
965
+ this.classProjectionComputes += 1;
896
966
  const projectedClassCandidates = this
897
967
  .mapCandidatesAlongPath(graph, path, createClassSymbolRecord(internalName.replace(/\//g, ".")))
898
968
  .filter((candidate) => candidate.kind === "class");
899
969
  if (projectedClassCandidates.length !== 1) {
900
970
  complete = false;
971
+ classProjectionCache.set(cacheKey, null);
901
972
  return fullMatch;
902
973
  }
903
974
  const projectedInternalName = projectedClassCandidates[0].symbol.replace(/\./g, "/");
904
- classProjectionCache.set(internalName, projectedInternalName);
975
+ classProjectionCache.set(cacheKey, projectedInternalName);
905
976
  return `L${projectedInternalName};`;
906
977
  });
907
978
  return {
@@ -1031,6 +1102,7 @@ export class MappingService {
1031
1102
  adjacency: new Map(),
1032
1103
  pathCache: new Map(),
1033
1104
  recordsByTarget: new Map(),
1105
+ classProjectionCache: new Map(),
1034
1106
  warnings: [
1035
1107
  `Version ${version} is unobfuscated; mapping graph is empty because the runtime already uses deobfuscated names.`
1036
1108
  ]
@@ -1044,6 +1116,7 @@ export class MappingService {
1044
1116
  adjacency: new Map(),
1045
1117
  pathCache: new Map(),
1046
1118
  recordsByTarget: new Map(),
1119
+ classProjectionCache: new Map(),
1047
1120
  warnings: []
1048
1121
  };
1049
1122
  const mojangLoad = await this.loadMojangPairs(version);
@@ -1212,22 +1285,63 @@ async function extractTinyFromJar(jarPath, outputPath) {
1212
1285
  await writeFile(outputPath, tinyEntry.content, "utf8");
1213
1286
  return true;
1214
1287
  }
1288
+ /**
1289
+ * How long a resolved yarn build coordinate is trusted before re-checking the
1290
+ * Fabric Maven metadata for a newer build. Within this window, resolving a yarn
1291
+ * tiny is zero-network (it reuses the recorded coordinate); after it expires the
1292
+ * next resolve does ONE metadata fetch. This fence keeps the metadata fetch off
1293
+ * the remap cache-hit fast path while still picking up new yarn builds.
1294
+ */
1295
+ const YARN_METADATA_TTL_MS = 24 * 60 * 60 * 1000;
1296
+ function yarnMetaPath(cacheDir, version) {
1297
+ return join(cacheDir, "mappings", "yarn", `${version}.meta.json`);
1298
+ }
1299
+ function yarnCoordinateTinyPath(cacheDir, coordinate) {
1300
+ return join(cacheDir, "mappings", "yarn", `${coordinate}.tiny`);
1301
+ }
1302
+ function readYarnCoordinateMeta(metaPath) {
1303
+ if (!existsSync(metaPath)) {
1304
+ return undefined;
1305
+ }
1306
+ try {
1307
+ const parsed = JSON.parse(readFileSync(metaPath, "utf8"));
1308
+ if (typeof parsed.coordinate === "string" && typeof parsed.fetchedAt === "number") {
1309
+ return { coordinate: parsed.coordinate, fetchedAt: parsed.fetchedAt };
1310
+ }
1311
+ }
1312
+ catch {
1313
+ // Corrupt marker -> treat as absent and re-resolve.
1314
+ }
1315
+ return undefined;
1316
+ }
1317
+ function writeYarnCoordinateMeta(metaPath, meta) {
1318
+ mkdirSync(dirname(metaPath), { recursive: true });
1319
+ writeFileSync(metaPath, JSON.stringify(meta));
1320
+ }
1215
1321
  /**
1216
1322
  * Resolve and cache a Tiny v2 mapping file for the given Minecraft version.
1217
1323
  *
1324
+ * Intermediary is version-keyed (immutable per release). Yarn is coordinate-keyed:
1325
+ * Fabric publishes incrementing yarn builds for the same MC version, so the tiny
1326
+ * is cached at `mappings/yarn/${coordinate}.tiny` and the newest coordinate is
1327
+ * re-checked once per {@link YARN_METADATA_TTL_MS}. The resolved yarn coordinate
1328
+ * is returned so callers can key their own caches on the actual mappings used.
1329
+ *
1218
1330
  * @param version - Minecraft version (e.g. "1.20.4")
1219
1331
  * @param mapping - "intermediary" or "yarn"
1220
1332
  * @param cacheDir - The application cache directory
1221
1333
  * @param fetchFn - Optional fetch implementation for testing
1222
- * @returns Path to the extracted Tiny v2 file
1334
+ * @param options - forceRefresh bypasses the yarn metadata TTL; now overrides the clock (testing)
1335
+ * @returns The extracted Tiny v2 path and, for yarn, the resolved build coordinate
1223
1336
  */
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
- }
1337
+ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn, options) {
1229
1338
  const effectiveFetch = fetchFn ?? globalThis.fetch;
1230
1339
  if (mapping === "intermediary") {
1340
+ // Intermediary is immutable per MC release: version-keyed cache is correct.
1341
+ const cachedTiny = join(cacheDir, "mappings", `${version}-intermediary.tiny`);
1342
+ if (existsSync(cachedTiny)) {
1343
+ return { path: cachedTiny };
1344
+ }
1231
1345
  const url = `${FABRIC_MAVEN}/net/fabricmc/intermediary/${version}/intermediary-${version}-v2.jar`;
1232
1346
  const jarDest = defaultDownloadPath(cacheDir, url);
1233
1347
  const downloaded = await downloadToCache(url, jarDest, {
@@ -1250,11 +1364,30 @@ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn
1250
1364
  details: { version, jarPath: downloaded.path }
1251
1365
  });
1252
1366
  }
1253
- return cachedTiny;
1367
+ return { path: cachedTiny };
1368
+ }
1369
+ // yarn: coordinate-keyed + metadata-TTL fenced.
1370
+ const nowFn = options?.now ?? Date.now;
1371
+ const metaPath = yarnMetaPath(cacheDir, version);
1372
+ const existingMeta = readYarnCoordinateMeta(metaPath);
1373
+ // Within-TTL fast path: reuse the recorded coordinate with zero network.
1374
+ if (!options?.forceRefresh && existingMeta && nowFn() - existingMeta.fetchedAt < YARN_METADATA_TTL_MS) {
1375
+ const cachedCoordTiny = yarnCoordinateTinyPath(cacheDir, existingMeta.coordinate);
1376
+ if (existsSync(cachedCoordTiny)) {
1377
+ return { path: cachedCoordTiny, coordinate: existingMeta.coordinate };
1378
+ }
1254
1379
  }
1255
- // yarn
1256
1380
  const yarnCoordinates = await fetchYarnCoordinatesStandalone(version, effectiveFetch);
1381
+ // Network fallback: the metadata fetch failed/empty (fetchYarnCoordinatesStandalone
1382
+ // swallows errors -> []). Serve the last-known-good coordinate rather than failing,
1383
+ // so a transient Fabric Maven outage after TTL expiry does not break cache hits.
1257
1384
  if (yarnCoordinates.length === 0) {
1385
+ if (existingMeta) {
1386
+ const cachedCoordTiny = yarnCoordinateTinyPath(cacheDir, existingMeta.coordinate);
1387
+ if (existsSync(cachedCoordTiny)) {
1388
+ return { path: cachedCoordTiny, coordinate: existingMeta.coordinate };
1389
+ }
1390
+ }
1258
1391
  throw createError({
1259
1392
  code: ERROR_CODES.MAPPING_UNAVAILABLE,
1260
1393
  message: `No yarn builds found for Minecraft ${version}.`,
@@ -1262,6 +1395,12 @@ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn
1262
1395
  });
1263
1396
  }
1264
1397
  for (const coordinate of yarnCoordinates) {
1398
+ const coordTiny = yarnCoordinateTinyPath(cacheDir, coordinate);
1399
+ if (existsSync(coordTiny)) {
1400
+ // Already have this build's tiny; just refresh the TTL marker.
1401
+ writeYarnCoordinateMeta(metaPath, { coordinate, fetchedAt: nowFn() });
1402
+ return { path: coordTiny, coordinate };
1403
+ }
1265
1404
  const url = `${FABRIC_MAVEN}/net/fabricmc/yarn/${coordinate}/yarn-${coordinate}-v2.jar`;
1266
1405
  const jarDest = defaultDownloadPath(cacheDir, url);
1267
1406
  const downloaded = await downloadToCache(url, jarDest, {
@@ -1272,9 +1411,12 @@ export async function resolveTinyMappingFile(version, mapping, cacheDir, fetchFn
1272
1411
  if (!downloaded.ok || !downloaded.path) {
1273
1412
  continue;
1274
1413
  }
1275
- const extracted = await extractTinyFromJar(downloaded.path, cachedTiny);
1414
+ const extracted = await extractTinyFromJar(downloaded.path, coordTiny);
1276
1415
  if (extracted) {
1277
- return cachedTiny;
1416
+ // Ordering matters: the tiny exists before the marker is written, so a
1417
+ // crash never leaves a TTL-valid marker pointing at a missing tiny.
1418
+ writeYarnCoordinateMeta(metaPath, { coordinate, fetchedAt: nowFn() });
1419
+ return { path: coordTiny, coordinate };
1278
1420
  }
1279
1421
  }
1280
1422
  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 });
@@ -183,6 +183,17 @@ export function validateParsedAccessTransformer(parsed, membersByClass, warnings
183
183
  invalidCount++;
184
184
  continue;
185
185
  }
186
+ if (entry.targetKind === "wildcard-all" || entry.targetKind === "wildcard-method") {
187
+ // A wildcard transforms all members (or all methods); it is valid as long as
188
+ // the owner class resolved, which it has by this point.
189
+ validatedEntries.push({
190
+ ...entry,
191
+ valid: true,
192
+ ...(options?.includeRuntimeEvidence ? { resolvedInRuntime: true } : {})
193
+ });
194
+ validCount++;
195
+ continue;
196
+ }
186
197
  if (entry.targetKind === "field") {
187
198
  const fieldNames = allFieldNames(members);
188
199
  const matchedField = members.fields.find((member) => member.name === entry.name);
@@ -215,8 +226,29 @@ export function validateParsedAccessTransformer(parsed, membersByClass, warnings
215
226
  continue;
216
227
  }
217
228
  const methodNames = allMethodNames(members);
218
- const matchedMethod = members.methods.find((member) => member.name === entry.name && member.jvmDescriptor === entry.descriptor) ?? members.constructors.find((member) => member.name === entry.name && member.jvmDescriptor === entry.descriptor);
229
+ const sameNameMethods = [...members.methods, ...members.constructors].filter((member) => member.name === entry.name);
230
+ // Match by descriptor when one is supplied (AT method lines always carry one);
231
+ // fall back to a unique name match when it is absent.
232
+ const matchedMethod = entry.descriptor
233
+ ? sameNameMethods.find((member) => member.jvmDescriptor === entry.descriptor)
234
+ : sameNameMethods.length === 1
235
+ ? sameNameMethods[0]
236
+ : undefined;
219
237
  if (!matchedMethod) {
238
+ // Distinguish a true missing method from a descriptor mismatch so the user
239
+ // can see which overloads exist instead of a flat "not found".
240
+ if (entry.descriptor && sameNameMethods.length > 0) {
241
+ const available = [...new Set(sameNameMethods.map((member) => member.jvmDescriptor))];
242
+ validatedEntries.push({
243
+ ...entry,
244
+ valid: false,
245
+ issue: `Method "${entry.name}" exists in "${ownerFqn}" but no overload matches descriptor ${entry.descriptor} (available: ${available.join(", ")}).`,
246
+ suggestions: available,
247
+ ...(options?.includeRuntimeEvidence ? { resolvedInRuntime: false } : {})
248
+ });
249
+ invalidCount++;
250
+ continue;
251
+ }
220
252
  const suggestions = entry.name ? suggestSimilar(entry.name, methodNames) : [];
221
253
  validatedEntries.push({
222
254
  ...entry,