@adhisang/minecraft-modding-mcp 6.3.0 → 7.0.0-rc.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +13 -3
  3. package/dist/cache-policy.d.ts +71 -0
  4. package/dist/cache-policy.js +83 -0
  5. package/dist/cache-registry.js +45 -8
  6. package/dist/cli.js +74 -3
  7. package/dist/compat-stdio-transport.d.ts +1 -1
  8. package/dist/compat-stdio-transport.js +13 -1
  9. package/dist/config.d.ts +3 -0
  10. package/dist/config.js +8 -2
  11. package/dist/decompiler/vineflower.d.ts +1 -0
  12. package/dist/decompiler/vineflower.js +8 -5
  13. package/dist/entry-tools/analyze-mod-service.d.ts +70 -136
  14. package/dist/entry-tools/analyze-symbol-service.d.ts +112 -150
  15. package/dist/entry-tools/compare-minecraft-service.d.ts +59 -145
  16. package/dist/entry-tools/entry-tool-schema.d.ts +38 -4
  17. package/dist/entry-tools/entry-tool-schema.js +4 -1
  18. package/dist/entry-tools/inspect-minecraft/internal.d.ts +235 -799
  19. package/dist/entry-tools/inspect-minecraft/internal.js +50 -13
  20. package/dist/entry-tools/inspect-minecraft-service.d.ts +372 -1736
  21. package/dist/entry-tools/manage-cache-service.d.ts +81 -91
  22. package/dist/entry-tools/validate-project/cases/mixin.js +26 -6
  23. package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
  24. package/dist/entry-tools/validate-project-service.d.ts +164 -592
  25. package/dist/entry-tools/verify-mixin-target-service.d.ts +3 -19
  26. package/dist/entry-tools/verify-mixin-target-service.js +19 -1
  27. package/dist/era-classifier.d.ts +161 -0
  28. package/dist/era-classifier.js +292 -0
  29. package/dist/error-mapping.d.ts +76 -0
  30. package/dist/error-mapping.js +116 -8
  31. package/dist/index.d.ts +42 -4
  32. package/dist/index.js +636 -473
  33. package/dist/java-process.d.ts +2 -0
  34. package/dist/java-process.js +22 -2
  35. package/dist/json-rpc-framing.d.ts +77 -1
  36. package/dist/json-rpc-framing.js +249 -13
  37. package/dist/mapping/loaders/tiny-loom-selection.d.ts +88 -0
  38. package/dist/mapping/loaders/tiny-loom-selection.js +223 -0
  39. package/dist/mapping/loaders/tiny-loom.js +45 -33
  40. package/dist/mapping/loaders/tiny-maven.js +6 -11
  41. package/dist/mapping/parsers/tiny.d.ts +57 -0
  42. package/dist/mapping/parsers/tiny.js +99 -22
  43. package/dist/mapping-service.d.ts +19 -0
  44. package/dist/mapping-service.js +93 -9
  45. package/dist/maven-resolver.d.ts +18 -0
  46. package/dist/maven-resolver.js +20 -0
  47. package/dist/mcp-helpers.d.ts +19 -2
  48. package/dist/mcp-helpers.js +58 -9
  49. package/dist/minecraft-explorer-service.d.ts +1 -1
  50. package/dist/mixin/types.d.ts +8 -0
  51. package/dist/mod-analyzer.js +7 -7
  52. package/dist/mod-decompile-service.js +1 -0
  53. package/dist/nbt/java-nbt-codec.js +12 -2
  54. package/dist/nbt/json-patch.js +14 -3
  55. package/dist/nbt/pipeline.js +40 -3
  56. package/dist/nbt/typed-json.js +26 -1
  57. package/dist/registration-adapter.d.ts +32 -0
  58. package/dist/registration-adapter.js +52 -0
  59. package/dist/repo-downloader.d.ts +165 -0
  60. package/dist/repo-downloader.js +568 -10
  61. package/dist/request-context.d.ts +7 -0
  62. package/dist/request-context.js +9 -0
  63. package/dist/resources.d.ts +1 -1
  64. package/dist/resources.js +25 -19
  65. package/dist/server-identity.d.ts +27 -0
  66. package/dist/server-identity.js +26 -0
  67. package/dist/source/access-validate.js +53 -0
  68. package/dist/source/artifact-resolver.d.ts +81 -2
  69. package/dist/source/artifact-resolver.js +227 -15
  70. package/dist/source/class-source.d.ts +36 -0
  71. package/dist/source/class-source.js +222 -38
  72. package/dist/source/did-you-mean.d.ts +12 -1
  73. package/dist/source/did-you-mean.js +6 -2
  74. package/dist/source/file-access.js +150 -46
  75. package/dist/source/indexer.js +1 -0
  76. package/dist/source/shared-utils.d.ts +21 -0
  77. package/dist/source/shared-utils.js +23 -0
  78. package/dist/source-resolver.js +224 -57
  79. package/dist/source-service.d.ts +12 -1
  80. package/dist/stdio-supervisor.d.ts +357 -2
  81. package/dist/stdio-supervisor.js +1031 -80
  82. package/dist/storage/db.d.ts +2 -1
  83. package/dist/storage/db.js +15 -8
  84. package/dist/synthetic-decorator.d.ts +24 -0
  85. package/dist/synthetic-decorator.js +48 -0
  86. package/dist/tool-guidance.d.ts +17 -1
  87. package/dist/tool-guidance.js +323 -18
  88. package/dist/tool-schema-registry.d.ts +2 -0
  89. package/dist/tool-schema-registry.js +4 -0
  90. package/dist/tool-schemas.d.ts +2212 -3919
  91. package/dist/tool-schemas.js +33 -7
  92. package/dist/types.d.ts +35 -0
  93. package/dist/v1-parity-schemas.d.ts +7 -0
  94. package/dist/v1-parity-schemas.js +5584 -0
  95. package/dist/version-diff-service.d.ts +33 -0
  96. package/dist/version-diff-service.js +148 -3
  97. package/dist/version-service.js +36 -14
  98. package/dist/warning-details.js +18 -1
  99. package/docs/README-ja.md +5 -3
  100. package/docs/tool-reference.md +196 -19
  101. package/package.json +13 -8
@@ -1,9 +1,84 @@
1
- import { createWriteStream, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { createError, ERROR_CODES } from "./errors.js";
7
+ /**
8
+ * Sidecar schema generation.
9
+ *
10
+ * v1 recorded only `contentLength` beside the digest, which bound the record to
11
+ * the *size* of the bytes rather than to the bytes: a jar replaced by a
12
+ * different jar of the same length kept reporting the old digest, so the
13
+ * artifact id described content nobody would read. v2 adds `contentMtimeMs`.
14
+ * Bumping rather than tolerating the older shape is the point - a v1 record is
15
+ * read as absent (re-hash), never as this file's identity.
16
+ */
17
+ const DOWNLOAD_SIDECAR_VERSION = 2;
18
+ const DOWNLOAD_SIDECAR_SUFFIX = ".cache.json";
19
+ /**
20
+ * Statuses that are a repository's definitive answer about this artifact rather
21
+ * than a passing failure.
22
+ *
23
+ * - 404: it is not here.
24
+ * - 410: it was here and is deliberately gone.
25
+ * - 403: it is refused - the routine answer of an object-store-backed Maven
26
+ * mirror for an object that is missing when listing is denied.
27
+ *
28
+ * None of them get the stale-if-error fallback. Serving the cached copy would
29
+ * hide the withdrawal for as long as the file survives and, because the caller
30
+ * reads success, would stop the repository loop from ever asking the next
31
+ * repository - which may still publish the artifact.
32
+ *
33
+ * Everything else keeps the fallback, including 401 and 400: those say something
34
+ * about our *request* (an expired credential, a proxy mangling it), not about
35
+ * whether this artifact exists, and dropping bytes we already hold over one
36
+ * would be a self-inflicted outage. 408/425/429 and 5xx are transient by
37
+ * definition, as is a thrown network error.
38
+ */
39
+ const DEFINITIVE_REJECTION_STATUS_CODES = new Set([403, 404, 410]);
40
+ function isDefinitiveRejection(statusCode) {
41
+ return statusCode !== undefined && DEFINITIVE_REJECTION_STATUS_CODES.has(statusCode);
42
+ }
43
+ /** Location of the sidecar describing `destinationPath`. Destination-adjacent. */
44
+ export function downloadSidecarPath(destinationPath) {
45
+ return `${destinationPath}${DOWNLOAD_SIDECAR_SUFFIX}`;
46
+ }
47
+ /** Suffix of the temp file an in-flight sidecar write holds before its rename. */
48
+ const DOWNLOAD_SIDECAR_TEMP_SUFFIX = ".tmp";
49
+ /** The temp path {@link writeDownloadSidecar} fills before renaming into place. */
50
+ function downloadSidecarTempPath(destinationPath) {
51
+ const unique = randomBytes(4).toString("hex");
52
+ return `${downloadSidecarPath(destinationPath)}.${unique}${DOWNLOAD_SIDECAR_TEMP_SUFFIX}`;
53
+ }
54
+ /**
55
+ * Whether `filePath` is a download sidecar - finished or half-written - rather
56
+ * than a downloaded artifact.
57
+ *
58
+ * This module owns the sidecar naming scheme, so consumers that walk the
59
+ * download cache (the cache registry inventory) ask here instead of matching
60
+ * the suffix themselves. The in-flight form counts: a process killed inside
61
+ * {@link writeDownloadSidecar} leaves `<jar>.cache.json.<hex>.tmp`, which is
62
+ * still a description of a jar and must never be inventoried as a cached
63
+ * artifact in its own right. Widening the predicate rather than renaming the
64
+ * temp into the plain suffix is what also covers the leftovers already sitting
65
+ * in caches written by earlier builds - the only leftovers that exist today.
66
+ *
67
+ * The jar temps {@link downloadToCache} writes (`<jar>.<hex>.tmp`, no sidecar
68
+ * suffix) are deliberately not matched: those are truncated *artifacts*, and
69
+ * the inventory has always listed them as such.
70
+ */
71
+ export function isDownloadSidecarPath(filePath) {
72
+ if (filePath.endsWith(DOWNLOAD_SIDECAR_SUFFIX)) {
73
+ return true;
74
+ }
75
+ if (!filePath.endsWith(DOWNLOAD_SIDECAR_TEMP_SUFFIX)) {
76
+ return false;
77
+ }
78
+ const withoutTempSuffix = filePath.slice(0, -DOWNLOAD_SIDECAR_TEMP_SUFFIX.length);
79
+ const uniqueStart = withoutTempSuffix.lastIndexOf(".");
80
+ return uniqueStart > 0 && withoutTempSuffix.slice(0, uniqueStart).endsWith(DOWNLOAD_SIDECAR_SUFFIX);
81
+ }
7
82
  function isHttpUrl(url) {
8
83
  try {
9
84
  const parsed = new URL(url);
@@ -13,6 +88,15 @@ function isHttpUrl(url) {
13
88
  return false;
14
89
  }
15
90
  }
91
+ function requireHttpUrl(url) {
92
+ if (!isHttpUrl(url)) {
93
+ throw createError({
94
+ code: ERROR_CODES.INVALID_INPUT,
95
+ message: `Unsupported scheme for download URL: ${url}`,
96
+ details: { url }
97
+ });
98
+ }
99
+ }
16
100
  function sleep(ms) {
17
101
  return new Promise((resolve) => setTimeout(resolve, ms));
18
102
  }
@@ -22,34 +106,487 @@ function sha256(input) {
22
106
  function retryDelay(baseMs, attempt) {
23
107
  return Math.floor(baseMs * 2 ** attempt + Math.random() * 128);
24
108
  }
109
+ /** Upper bound on how long a repository-supplied `Retry-After` may pause a retry. */
110
+ const MAX_RETRY_AFTER_MS = 30_000;
111
+ /** Stream the file through sha256 so a multi-hundred-megabyte jar never lands in memory. */
112
+ async function digestFile(filePath) {
113
+ const hash = createHash("sha256");
114
+ let contentLength = 0;
115
+ for await (const chunk of createReadStream(filePath)) {
116
+ const buffer = chunk;
117
+ hash.update(buffer);
118
+ contentLength += buffer.length;
119
+ }
120
+ return { contentSha256: hash.digest("hex"), contentLength };
121
+ }
122
+ /**
123
+ * Derive the identity of the file at `filePath`.
124
+ *
125
+ * The stat is taken *before* the digest on purpose. If the bytes are replaced
126
+ * while we are hashing them, the mtime we recorded is the one they no longer
127
+ * have, so the record we write is rejected on the next read and the digest is
128
+ * re-derived - a wasted hash, never a wrong identity. Stamping afterwards would
129
+ * pair a digest of mixed bytes with a stat that vouches for it.
130
+ */
131
+ async function describeFile(filePath) {
132
+ const stats = statSync(filePath);
133
+ const digest = await digestFile(filePath);
134
+ return {
135
+ contentSha256: digest.contentSha256,
136
+ contentLength: stats.size,
137
+ contentMtimeMs: stats.mtimeMs
138
+ };
139
+ }
140
+ /**
141
+ * Whether `sidecar`'s recorded identity still matches the bytes currently at
142
+ * `destinationPath`. `sidecar` is always read before the network request that
143
+ * motivated the caller to ask, so a concurrent resolve of the same mutable
144
+ * coordinate can have replaced the file in between - this is a stat, not a
145
+ * read, the same cross-check `readDownloadSidecar` applies on an ordinary hit.
146
+ */
147
+ function sidecarStillCurrent(destinationPath, sidecar) {
148
+ if (!sidecar) {
149
+ return false;
150
+ }
151
+ try {
152
+ const current = statSync(destinationPath);
153
+ return current.size === sidecar.contentLength && current.mtimeMs === sidecar.contentMtimeMs;
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
159
+ /**
160
+ * Read the sidecar defensively: a missing, corrupt, partial, foreign-URL,
161
+ * older-schema or stat-mismatched record is reported as absent so the caller
162
+ * re-derives the digest from the bytes instead of trusting a record that may
163
+ * describe something else.
164
+ */
165
+ function readDownloadSidecar(destinationPath, url) {
166
+ const sidecarPath = downloadSidecarPath(destinationPath);
167
+ if (!existsSync(sidecarPath)) {
168
+ return undefined;
169
+ }
170
+ try {
171
+ const parsed = JSON.parse(readFileSync(sidecarPath, "utf8"));
172
+ if (parsed.version !== DOWNLOAD_SIDECAR_VERSION || parsed.url !== url) {
173
+ return undefined;
174
+ }
175
+ if (typeof parsed.contentSha256 !== "string" || parsed.contentSha256.length === 0) {
176
+ return undefined;
177
+ }
178
+ if (typeof parsed.contentLength !== "number" || !Number.isFinite(parsed.contentLength)) {
179
+ return undefined;
180
+ }
181
+ if (typeof parsed.contentMtimeMs !== "number" || !Number.isFinite(parsed.contentMtimeMs)) {
182
+ return undefined;
183
+ }
184
+ if (parsed.etag !== undefined && typeof parsed.etag !== "string") {
185
+ return undefined;
186
+ }
187
+ if (parsed.lastModified !== undefined && typeof parsed.lastModified !== "string") {
188
+ return undefined;
189
+ }
190
+ // Cross-check against the bytes the record claims to describe: a truncated
191
+ // or externally replaced file makes the recorded digest a lie. Size alone
192
+ // cannot see a same-length replacement - a different jar swapped in by hand,
193
+ // or installed by a concurrent resolve inside this module's own
194
+ // sidecar-less window - so the mtime has to match too. That is a stat, not a
195
+ // read: a cache hit stays free, which is the entire reason the record exists.
196
+ const stats = statSync(destinationPath);
197
+ if (stats.size !== parsed.contentLength || stats.mtimeMs !== parsed.contentMtimeMs) {
198
+ return undefined;
199
+ }
200
+ return {
201
+ version: DOWNLOAD_SIDECAR_VERSION,
202
+ url,
203
+ contentSha256: parsed.contentSha256,
204
+ contentLength: parsed.contentLength,
205
+ contentMtimeMs: parsed.contentMtimeMs,
206
+ etag: parsed.etag,
207
+ lastModified: parsed.lastModified
208
+ };
209
+ }
210
+ catch {
211
+ // Corrupt record -> treat as absent and re-derive.
212
+ return undefined;
213
+ }
214
+ }
215
+ /**
216
+ * Write the sidecar atomically (temp file + rename).
217
+ *
218
+ * Ordering matters, in both directions. Bytes are always written before the
219
+ * sidecar that describes them, and a sidecar describing bytes that are about to
220
+ * be replaced is retired first ({@link retireDownloadSidecar}). A crash can
221
+ * therefore only ever leave a sidecar-less file - re-hashed on the next read -
222
+ * never a sidecar describing a file that is not there. A failure to write is
223
+ * swallowed on purpose: the cached bytes are still usable and the next call
224
+ * simply re-derives the digest.
225
+ */
226
+ function writeDownloadSidecar(destinationPath, sidecar) {
227
+ const sidecarPath = downloadSidecarPath(destinationPath);
228
+ const tempPath = downloadSidecarTempPath(destinationPath);
229
+ try {
230
+ mkdirSync(dirname(sidecarPath), { recursive: true });
231
+ writeFileSync(tempPath, JSON.stringify(sidecar));
232
+ renameSync(tempPath, sidecarPath);
233
+ }
234
+ catch {
235
+ try {
236
+ unlinkSync(tempPath);
237
+ }
238
+ catch {
239
+ // best-effort cleanup
240
+ }
241
+ }
242
+ }
243
+ /**
244
+ * Delete the record describing `destinationPath`, reporting whether one was
245
+ * there to delete.
246
+ *
247
+ * This opens a deliberately sidecar-less window around a byte replacement: for
248
+ * the length of the transfer the file has no identity record at all, a state the
249
+ * read path handles by re-deriving the digest, instead of a record describing
250
+ * bytes that no longer exist, which it can only catch when the sizes happen to
251
+ * differ.
252
+ */
253
+ function retireDownloadSidecar(destinationPath) {
254
+ try {
255
+ unlinkSync(downloadSidecarPath(destinationPath));
256
+ return true;
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * Evict a cached download and its identity record.
264
+ *
265
+ * For a caller that got its bytes and then found them unusable - a 200 carrying
266
+ * an HTML error page instead of a jar, say. The transfer succeeded, so nothing
267
+ * in here can tell it apart from a real artifact, and for an immutable url the
268
+ * entry would otherwise be served straight back on every later run with no
269
+ * request made at all: the poison would outlive the outage that produced it.
270
+ *
271
+ * Record first, bytes second, matching {@link writeDownloadSidecar}'s ordering
272
+ * in reverse: no window ever holds a record describing bytes that are gone. Both
273
+ * steps are best-effort - a file we could not remove is at worst re-validated
274
+ * and re-evicted next time.
275
+ */
276
+ export function discardCachedDownload(destinationPath) {
277
+ retireDownloadSidecar(destinationPath);
278
+ try {
279
+ unlinkSync(destinationPath);
280
+ }
281
+ catch {
282
+ // best-effort eviction
283
+ }
284
+ }
285
+ function conditionalHeadersFor(sidecar) {
286
+ if (!sidecar) {
287
+ return undefined;
288
+ }
289
+ const headers = {};
290
+ if (sidecar.etag) {
291
+ headers["If-None-Match"] = sidecar.etag;
292
+ }
293
+ if (sidecar.lastModified) {
294
+ headers["If-Modified-Since"] = sidecar.lastModified;
295
+ }
296
+ return Object.keys(headers).length > 0 ? headers : undefined;
297
+ }
298
+ /** Reuse the bytes already on disk, deriving (and persisting) the digest if needed. */
299
+ async function cachedBytesResult(url, destinationPath, sidecar, cacheStatus) {
300
+ if (sidecar) {
301
+ return {
302
+ ok: true,
303
+ cacheStatus,
304
+ path: destinationPath,
305
+ contentLength: sidecar.contentLength,
306
+ contentSha256: sidecar.contentSha256,
307
+ etag: sidecar.etag,
308
+ lastModified: sidecar.lastModified
309
+ };
310
+ }
311
+ // Migration path: a jar cached by an older build has no readable sidecar -
312
+ // none at all, or one this build refuses. Hash it once and record the result
313
+ // so every later hit is free. No freshness data is carried out of here: a
314
+ // rejected record's validators are exactly as untrustworthy as its digest.
315
+ const identity = await describeFile(destinationPath);
316
+ writeDownloadSidecar(destinationPath, {
317
+ version: DOWNLOAD_SIDECAR_VERSION,
318
+ url,
319
+ ...identity
320
+ });
321
+ return {
322
+ ok: true,
323
+ cacheStatus,
324
+ path: destinationPath,
325
+ contentLength: identity.contentLength,
326
+ contentSha256: identity.contentSha256
327
+ };
328
+ }
329
+ /**
330
+ * Bytes of the file at `filePath`, or 0 when there is none.
331
+ *
332
+ * A zero-byte file is reported exactly like a missing one, on purpose: an empty
333
+ * artifact is never a usable cache entry - it fails the moment a decompiler or
334
+ * a zip reader opens it - so it must satisfy neither an immutable hit nor a
335
+ * stale-if-error fallback. Treating it as absent lets the next transfer replace
336
+ * it instead of pinning it forever.
337
+ */
338
+ function cachedByteCount(filePath) {
339
+ try {
340
+ return statSync(filePath).size;
341
+ }
342
+ catch {
343
+ return 0;
344
+ }
345
+ }
346
+ /**
347
+ * Resolve a URL into a cached file, deciding here - inside the module that owns
348
+ * the download cache - whether the network is needed at all.
349
+ *
350
+ * `freshness: "immutable"` never asks the network once the file exists.
351
+ * `freshness: "revalidate"` confirms known validators with a conditional
352
+ * request and keeps the cached bytes on a 304. When revalidation fails
353
+ * *transiently* (offline repo, 5xx, rate limiting) the cached bytes are still
354
+ * served - they are a byte-exact copy of what the repository handed out before,
355
+ * and losing them to a passing failure would be a regression over the previous
356
+ * unconditional "file exists -> reuse it" behaviour - but they are reported as
357
+ * `cacheStatus: "stale"`, never as a confirmed hit. A definitive rejection
358
+ * ({@link DEFINITIVE_REJECTION_STATUS_CODES}) is reported as the failure it is,
359
+ * so the caller can fail over to another repository.
360
+ *
361
+ * A zero-byte answer is refused rather than cached: see {@link cachedByteCount}.
362
+ *
363
+ * @param url - The artifact URL; also the identity the sidecar is bound to.
364
+ * @param destinationPath - Where the bytes live; the sidecar sits next to it.
365
+ * @param options - Download options plus the required freshness policy.
366
+ * @returns A result whose success arm always carries `path`, the real on-disk
367
+ * `contentLength`, and `contentSha256`, regardless of which leg produced it.
368
+ */
369
+ export async function resolveCachedDownload(url, destinationPath, options) {
370
+ requireHttpUrl(url);
371
+ const { freshness, requestHeaders, ...downloadOptions } = options;
372
+ const hasCachedBytes = cachedByteCount(destinationPath) > 0;
373
+ const sidecar = hasCachedBytes ? readDownloadSidecar(destinationPath, url) : undefined;
374
+ if (hasCachedBytes && freshness === "immutable") {
375
+ return await cachedBytesResult(url, destinationPath, sidecar, "hit");
376
+ }
377
+ const conditional = freshness === "revalidate" ? conditionalHeadersFor(sidecar) : undefined;
378
+ const mergedHeaders = { ...(requestHeaders ?? {}), ...(conditional ?? {}) };
379
+ // The 200 leg below renames replacement bytes over `destinationPath`, and
380
+ // digesting a multi-hundred-megabyte jar afterwards is not instant. Retire the
381
+ // record of the OLD bytes before the transfer starts, so that whole window
382
+ // holds no sidecar at all rather than one describing bytes that are gone - the
383
+ // size cross-check in readDownloadSidecar only catches the latter when the
384
+ // sizes happen to differ. `sidecar` is already in memory, so it still supplies
385
+ // the conditional validators, and every leg that keeps the old bytes puts the
386
+ // record straight back.
387
+ const retiredSidecar = hasCachedBytes ? retireDownloadSidecar(destinationPath) : false;
388
+ /**
389
+ * Reuse the bytes already on disk, restoring the retired record first.
390
+ *
391
+ * `sidecar` was read before the transfer attempt went out, so a concurrent
392
+ * resolve of the same mutable coordinate can have replaced `destinationPath`
393
+ * while this request was in flight - trust `sidecar`'s identity only if the
394
+ * file still matches it, the same check the notModified branch below
395
+ * applies, otherwise re-derive from whatever bytes are actually there.
396
+ *
397
+ * Returns undefined instead of throwing: every caller of this is already on a
398
+ * failure path, and a second failure here (the file was pruned between the
399
+ * stat and the read, the disk is unreadable) must not replace the reason the
400
+ * call failed with a less informative one.
401
+ */
402
+ const serveCachedBytes = async (cacheStatus) => {
403
+ if (!hasCachedBytes) {
404
+ return undefined;
405
+ }
406
+ try {
407
+ const usable = sidecarStillCurrent(destinationPath, sidecar) ? sidecar : undefined;
408
+ if (retiredSidecar && usable) {
409
+ writeDownloadSidecar(destinationPath, usable);
410
+ }
411
+ return await cachedBytesResult(url, destinationPath, usable, cacheStatus);
412
+ }
413
+ catch {
414
+ return undefined;
415
+ }
416
+ };
417
+ let downloaded;
418
+ try {
419
+ downloaded = await downloadToCache(url, destinationPath, {
420
+ ...downloadOptions,
421
+ requestHeaders: Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined
422
+ });
423
+ }
424
+ catch (caughtError) {
425
+ // Stale-if-error: a byte-exact copy of what the repository handed out before
426
+ // beats failing outright, as long as the caller is told it is unconfirmed.
427
+ const stale = await serveCachedBytes("stale");
428
+ if (stale) {
429
+ return stale;
430
+ }
431
+ throw caughtError;
432
+ }
433
+ if (downloaded.notModified && hasCachedBytes) {
434
+ // `sidecar` was read before the conditional request went out, so a
435
+ // concurrent resolve of the same mutable coordinate can have replaced
436
+ // `destinationPath` while this request was in flight - the 304 we just
437
+ // got answers for the OLD bytes. Trust `sidecar`'s identity only if the
438
+ // file still matches it; otherwise re-derive from what's actually there,
439
+ // the same stat-then-digest check readDownloadSidecar applies on read.
440
+ const identity = sidecar !== undefined && sidecarStillCurrent(destinationPath, sidecar)
441
+ ? sidecar
442
+ : {
443
+ version: DOWNLOAD_SIDECAR_VERSION,
444
+ url,
445
+ ...(await describeFile(destinationPath))
446
+ };
447
+ const refreshed = {
448
+ ...identity,
449
+ etag: downloaded.etag ?? identity.etag,
450
+ lastModified: downloaded.lastModified ?? identity.lastModified
451
+ };
452
+ writeDownloadSidecar(destinationPath, refreshed);
453
+ return {
454
+ ok: true,
455
+ cacheStatus: "revalidated",
456
+ statusCode: downloaded.statusCode,
457
+ path: destinationPath,
458
+ contentLength: refreshed.contentLength,
459
+ contentSha256: refreshed.contentSha256,
460
+ etag: refreshed.etag,
461
+ lastModified: refreshed.lastModified
462
+ };
463
+ }
464
+ if (!downloaded.ok || !downloaded.path) {
465
+ if (isDefinitiveRejection(downloaded.statusCode)) {
466
+ // The repository has answered about this artifact, not about its own
467
+ // health, and it will answer the same way next time. Report the failure so
468
+ // the caller's repository loop can move on; a cached copy standing in here
469
+ // would look like success and end the search at a repository that has
470
+ // nothing. The bytes stay on disk - evicting the last copy in reach is not
471
+ // this module's call - and the record goes back with them, so a later
472
+ // revalidation that finds the artifact restored still has its validators.
473
+ if (retiredSidecar && sidecar) {
474
+ writeDownloadSidecar(destinationPath, sidecar);
475
+ }
476
+ return {
477
+ ok: false,
478
+ statusCode: downloaded.statusCode,
479
+ etag: downloaded.etag,
480
+ lastModified: downloaded.lastModified,
481
+ contentLength: downloaded.contentLength
482
+ };
483
+ }
484
+ // The same stale-if-error reuse, for a repository that answered rather than
485
+ // failed to: a 5xx, a 429, an authentication hiccup. Nothing here says the
486
+ // artifact is gone, so bytes we already hold beat failing outright.
487
+ const stale = await serveCachedBytes("stale");
488
+ if (stale) {
489
+ return stale;
490
+ }
491
+ return {
492
+ ok: false,
493
+ statusCode: downloaded.statusCode,
494
+ etag: downloaded.etag,
495
+ lastModified: downloaded.lastModified,
496
+ contentLength: downloaded.contentLength
497
+ };
498
+ }
499
+ // Bytes first, sidecar second - see writeDownloadSidecar.
500
+ const digest = await describeFile(downloaded.path);
501
+ if (digest.contentLength === 0) {
502
+ // A 200 with no body is a failed transfer that happened to answer success.
503
+ // Recording it would park an empty artifact under this url - permanently, if
504
+ // the url is immutable - and every later hit would hand back bytes that only
505
+ // fail once something opens them. Drop the file and report the failure, so
506
+ // the next call transfers again instead of inheriting the emptiness.
507
+ try {
508
+ unlinkSync(downloaded.path);
509
+ }
510
+ catch {
511
+ // Best-effort: an empty file we could not remove is at least sidecar-less,
512
+ // and cachedByteCount refuses to treat it as a cache hit either way.
513
+ }
514
+ return {
515
+ ok: false,
516
+ statusCode: downloaded.statusCode,
517
+ etag: downloaded.etag,
518
+ lastModified: downloaded.lastModified,
519
+ contentLength: 0
520
+ };
521
+ }
522
+ writeDownloadSidecar(downloaded.path, {
523
+ version: DOWNLOAD_SIDECAR_VERSION,
524
+ url,
525
+ ...digest,
526
+ etag: downloaded.etag,
527
+ lastModified: downloaded.lastModified
528
+ });
529
+ return {
530
+ ok: true,
531
+ cacheStatus: "downloaded",
532
+ statusCode: downloaded.statusCode,
533
+ path: downloaded.path,
534
+ contentLength: digest.contentLength,
535
+ contentSha256: digest.contentSha256,
536
+ etag: downloaded.etag,
537
+ lastModified: downloaded.lastModified
538
+ };
539
+ }
540
+ /**
541
+ * Fetch `url` unconditionally and stream it into `destinationPath`.
542
+ *
543
+ * This is the raw transfer: it knows nothing about what is already cached.
544
+ * Prefer {@link resolveCachedDownload} for anything keyed by URL in the
545
+ * downloads cache.
546
+ */
25
547
  export async function downloadToCache(url, destinationPath, opts = {}) {
26
548
  const timeoutMs = opts.timeoutMs ?? 15000;
27
549
  const maxRetries = opts.retries ?? 2;
28
550
  const fetchFn = opts.fetchFn ?? globalThis.fetch;
29
- if (!isHttpUrl(url)) {
30
- throw createError({
31
- code: ERROR_CODES.INVALID_INPUT,
32
- message: `Unsupported scheme for download URL: ${url}`,
33
- details: { url }
34
- });
35
- }
551
+ requireHttpUrl(url);
36
552
  mkdirSync(dirname(destinationPath), { recursive: true });
37
553
  let attempt = 0;
38
554
  while (true) {
39
555
  const timeout = new AbortController();
40
556
  const timer = setTimeout(() => timeout.abort(), timeoutMs);
41
557
  try {
42
- const response = await fetchFn(url, { signal: timeout.signal });
558
+ const request = { signal: timeout.signal };
559
+ if (opts.requestHeaders && Object.keys(opts.requestHeaders).length > 0) {
560
+ request.headers = opts.requestHeaders;
561
+ }
562
+ const response = await fetchFn(url, request);
43
563
  const status = response.status;
44
564
  if (status === 404) {
45
565
  return { ok: false, statusCode: status };
46
566
  }
567
+ // A conditional request that the server answered with "unchanged". Not an
568
+ // error: the caller already holds the bytes. `ok` stays false because this
569
+ // call wrote none.
570
+ if (status === 304) {
571
+ return {
572
+ ok: false,
573
+ statusCode: status,
574
+ notModified: true,
575
+ etag: response.headers.get("etag") ?? undefined,
576
+ lastModified: response.headers.get("last-modified") ?? undefined
577
+ };
578
+ }
47
579
  if (status === 429 || (status >= 500 && status < 600)) {
48
580
  if (attempt >= maxRetries) {
49
581
  return { ok: false, statusCode: status };
50
582
  }
51
583
  const retryAfter = Number.parseInt(response.headers.get("retry-after") ?? "", 10);
52
- const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : retryDelay(200, attempt);
584
+ // A repository-supplied Retry-After is a hint, not a mandate: an
585
+ // unreasonable value (or a hostile one) must not stall the caller far
586
+ // past what a retry is worth, so it is capped rather than trusted whole.
587
+ const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
588
+ ? Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS)
589
+ : retryDelay(200, attempt);
53
590
  await sleep(waitMs);
54
591
  attempt += 1;
55
592
  continue;
@@ -73,6 +610,27 @@ export async function downloadToCache(url, destinationPath, opts = {}) {
73
610
  await pipeline(readable, createWriteStream(tempPath), { signal: timeout.signal });
74
611
  }
75
612
  const contentLength = statSync(tempPath).size;
613
+ // A 200 with no body is a failed transfer that happened to answer
614
+ // success. Renaming it onto `destinationPath` before this is known would
615
+ // overwrite any good bytes already cached there - the caller's
616
+ // stale-if-error fallback below only has bytes to fall back to if this
617
+ // leg never destroys them. Report it exactly like any other failed leg
618
+ // instead: no `path`, `ok: false`, temp file discarded.
619
+ if (contentLength === 0) {
620
+ try {
621
+ unlinkSync(tempPath);
622
+ }
623
+ catch {
624
+ // best-effort cleanup
625
+ }
626
+ return {
627
+ ok: false,
628
+ statusCode: status,
629
+ etag: response.headers.get("etag") ?? undefined,
630
+ lastModified: response.headers.get("last-modified") ?? undefined,
631
+ contentLength: 0
632
+ };
633
+ }
76
634
  renameSync(tempPath, destinationPath);
77
635
  return {
78
636
  ok: true,
@@ -0,0 +1,7 @@
1
+ export type RequestContext = {
2
+ requestId: string;
3
+ deadlineAt?: number;
4
+ signal?: AbortSignal;
5
+ };
6
+ export declare function runWithRequestContext<T>(context: RequestContext, fn: () => T): T;
7
+ export declare function getRequestContext(): RequestContext | undefined;
@@ -0,0 +1,9 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const requestContextStorage = new AsyncLocalStorage();
3
+ export function runWithRequestContext(context, fn) {
4
+ return requestContextStorage.run(context, fn);
5
+ }
6
+ export function getRequestContext() {
7
+ return requestContextStorage.getStore();
8
+ }
9
+ //# sourceMappingURL=request-context.js.map
@@ -1,3 +1,3 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import type { SourceService } from "./source-service.js";
3
3
  export declare function registerResources(server: McpServer, sourceService: SourceService): void;