@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.
- package/CHANGELOG.md +90 -0
- package/README.md +13 -3
- package/dist/cache-policy.d.ts +71 -0
- package/dist/cache-policy.js +83 -0
- package/dist/cache-registry.js +45 -8
- package/dist/cli.js +74 -3
- package/dist/compat-stdio-transport.d.ts +1 -1
- package/dist/compat-stdio-transport.js +13 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +8 -2
- package/dist/decompiler/vineflower.d.ts +1 -0
- package/dist/decompiler/vineflower.js +8 -5
- package/dist/entry-tools/analyze-mod-service.d.ts +70 -136
- package/dist/entry-tools/analyze-symbol-service.d.ts +112 -150
- package/dist/entry-tools/compare-minecraft-service.d.ts +59 -145
- package/dist/entry-tools/entry-tool-schema.d.ts +38 -4
- package/dist/entry-tools/entry-tool-schema.js +4 -1
- package/dist/entry-tools/inspect-minecraft/internal.d.ts +235 -799
- package/dist/entry-tools/inspect-minecraft/internal.js +50 -13
- package/dist/entry-tools/inspect-minecraft-service.d.ts +372 -1736
- package/dist/entry-tools/manage-cache-service.d.ts +81 -91
- package/dist/entry-tools/validate-project/cases/mixin.js +26 -6
- package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
- package/dist/entry-tools/validate-project-service.d.ts +164 -592
- package/dist/entry-tools/verify-mixin-target-service.d.ts +3 -19
- package/dist/entry-tools/verify-mixin-target-service.js +19 -1
- package/dist/era-classifier.d.ts +161 -0
- package/dist/era-classifier.js +292 -0
- package/dist/error-mapping.d.ts +76 -0
- package/dist/error-mapping.js +116 -8
- package/dist/index.d.ts +42 -4
- package/dist/index.js +636 -473
- package/dist/java-process.d.ts +2 -0
- package/dist/java-process.js +22 -2
- package/dist/json-rpc-framing.d.ts +77 -1
- package/dist/json-rpc-framing.js +249 -13
- package/dist/mapping/loaders/tiny-loom-selection.d.ts +88 -0
- package/dist/mapping/loaders/tiny-loom-selection.js +223 -0
- package/dist/mapping/loaders/tiny-loom.js +45 -33
- package/dist/mapping/loaders/tiny-maven.js +6 -11
- package/dist/mapping/parsers/tiny.d.ts +57 -0
- package/dist/mapping/parsers/tiny.js +99 -22
- package/dist/mapping-service.d.ts +19 -0
- package/dist/mapping-service.js +93 -9
- package/dist/maven-resolver.d.ts +18 -0
- package/dist/maven-resolver.js +20 -0
- package/dist/mcp-helpers.d.ts +19 -2
- package/dist/mcp-helpers.js +58 -9
- package/dist/minecraft-explorer-service.d.ts +1 -1
- package/dist/mixin/types.d.ts +8 -0
- package/dist/mod-analyzer.js +7 -7
- package/dist/mod-decompile-service.js +1 -0
- package/dist/nbt/java-nbt-codec.js +12 -2
- package/dist/nbt/json-patch.js +14 -3
- package/dist/nbt/pipeline.js +40 -3
- package/dist/nbt/typed-json.js +26 -1
- package/dist/registration-adapter.d.ts +32 -0
- package/dist/registration-adapter.js +52 -0
- package/dist/repo-downloader.d.ts +165 -0
- package/dist/repo-downloader.js +568 -10
- package/dist/request-context.d.ts +7 -0
- package/dist/request-context.js +9 -0
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +25 -19
- package/dist/server-identity.d.ts +27 -0
- package/dist/server-identity.js +26 -0
- package/dist/source/access-validate.js +53 -0
- package/dist/source/artifact-resolver.d.ts +81 -2
- package/dist/source/artifact-resolver.js +227 -15
- package/dist/source/class-source.d.ts +36 -0
- package/dist/source/class-source.js +222 -38
- package/dist/source/did-you-mean.d.ts +12 -1
- package/dist/source/did-you-mean.js +6 -2
- package/dist/source/file-access.js +150 -46
- package/dist/source/indexer.js +1 -0
- package/dist/source/shared-utils.d.ts +21 -0
- package/dist/source/shared-utils.js +23 -0
- package/dist/source-resolver.js +224 -57
- package/dist/source-service.d.ts +12 -1
- package/dist/stdio-supervisor.d.ts +357 -2
- package/dist/stdio-supervisor.js +1031 -80
- package/dist/storage/db.d.ts +2 -1
- package/dist/storage/db.js +15 -8
- package/dist/synthetic-decorator.d.ts +24 -0
- package/dist/synthetic-decorator.js +48 -0
- package/dist/tool-guidance.d.ts +17 -1
- package/dist/tool-guidance.js +323 -18
- package/dist/tool-schema-registry.d.ts +2 -0
- package/dist/tool-schema-registry.js +4 -0
- package/dist/tool-schemas.d.ts +2212 -3919
- package/dist/tool-schemas.js +33 -7
- package/dist/types.d.ts +35 -0
- package/dist/v1-parity-schemas.d.ts +7 -0
- package/dist/v1-parity-schemas.js +5584 -0
- package/dist/version-diff-service.d.ts +33 -0
- package/dist/version-diff-service.js +148 -3
- package/dist/version-service.js +36 -14
- package/dist/warning-details.js +18 -1
- package/docs/README-ja.md +5 -3
- package/docs/tool-reference.md +196 -19
- package/package.json +13 -8
package/dist/repo-downloader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/resources.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/
|
|
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;
|