endfield-mcp
Version:
MCP Server for Arknights: Endfield (TypeScript / stdio + Streamable HTTP)
501 lines • 20.5 kB
JavaScript
/**
* GitHub-backed data sync for Endfield-MCP.
*
* Mirrors the design of PRTS-MCP's `ts/src/data/sync.ts` — the same proven
* decision tree, cascade fallback, and atomic-write semantics. The only
* Endfield-specific parts are the User-Agent and the absence of the
* `upstream-<sha>` tag convention (our mirror uses plain semver tags, so
* commitSha === tag verbatim).
*
* Contract:
* - Downloads GitHub Release zip assets only when the release tag changes.
* - Skips the upstream API call entirely when cached data is fresher than
* CACHE_TTL_SECONDS (avoids burning GitHub anonymous quota on every start).
* - Falls back gracefully to cached data when the network is unavailable,
* and to "no data" only when neither cache nor network can provide.
* - All writes are write-to-tmp-then-rename for crash safety.
*/
import { existsSync, statSync } from "node:fs";
import { mkdir, readFile, rename, unlink, writeFile, } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
import AdmZip from "adm-zip";
import { parseMirrors } from "../config.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// Version here is intentionally a coarse literal (major.minor), not derived
// from SERVER_VERSION: data/ must not depend on the upper server layer, and
// a precise patch version in a UA adds no value. Bump manually on minor+.
const GITHUB_UA = "Endfield-MCP-Bot/0.3 (Arknights: Endfield fan-creation helper)";
/** Skip the upstream tag check if cached data is fresher than this (seconds). */
const CACHE_TTL_SECONDS = 3600;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function githubHeaders() {
const headers = { "User-Agent": GITHUB_UA };
const token = process.env["GITHUB_TOKEN"];
if (token)
headers["Authorization"] = `Bearer ${token}`;
return headers;
}
/**
* Return mirror URLs parsed from GITHUB_MIRRORS env var.
* Delegates to config.ts:parseMirrors to keep parsing in one place.
* Mirror URL format (ghproxy-style): `<mirror>/<original_url>`.
*/
function mirrorUrls() {
return parseMirrors(process.env["GITHUB_MIRRORS"] ?? "");
}
/** Return `[url, mirroredUrl1, mirroredUrl2, ...]`. */
function urlCandidates(url) {
return [url, ...mirrorUrls().map((m) => `${m}/${url}`)];
}
/**
* fetch() wrapper that cascades through URL candidates on failure.
*
* - A fresh AbortSignal.timeout is created per attempt so an earlier timeout
* does not consume the budget for later candidates.
* - HTTP 4xx from the direct URL propagates immediately (resource is
* genuinely missing — mirrors won't help).
* - Network error or HTTP 5xx from any candidate → try the next one.
*/
async function fetchCascading(url, options, timeoutMs) {
const candidates = urlCandidates(url);
let lastErr = new Error("All URL candidates failed");
for (let i = 0; i < candidates.length; i++) {
try {
const res = await fetch(candidates[i], {
...options,
signal: AbortSignal.timeout(timeoutMs),
});
if (res.ok)
return res;
lastErr = new Error(`HTTP ${res.status}`);
// Direct 4xx → resource does not exist; mirrors cannot help.
if (i === 0 && res.status >= 400 && res.status < 500)
break;
}
catch (err) {
lastErr = err;
}
}
throw lastErr;
}
function errorMessage(err) {
return err instanceof Error ? err.message : String(err);
}
function cacheIsFresh(cache) {
try {
const ageMs = Date.now() - new Date(cache.fetchedAt).getTime();
return ageMs < CACHE_TTL_SECONDS * 1000;
}
catch {
return false;
}
}
function releaseZipError(spec) {
if (!existsSync(spec.localZip))
return "zip file is missing";
try {
const missing = spec.validateZip?.(spec.localZip) ?? [];
if (missing.length === 0)
return null;
return missing.slice(0, 10).join("; ");
}
catch (err) {
return `${basename(spec.localZip)} is not a valid zip: ${errorMessage(err)}`;
}
}
// ---------------------------------------------------------------------------
// Release-based sync
// ---------------------------------------------------------------------------
// We list releases (newest first) rather than hitting /releases/latest.
// The latter returns the single newest Release for the whole repo and assumes
// it carries every asset — which breaks once a repo ships multiple
// independent assets across different Releases (EndFieldGameData: tables zip
// in v0.2.0, story zip in v0.3.0, worldview zip in v0.4.0). Listing lets us
// pick the Release that actually contains the requested asset.
const GITHUB_RELEASES_LIST_URL = "https://api.github.com/repos/{owner}/{repo}/releases?per_page=30";
function releaseCachePath(spec) {
return join(dirname(spec.localZip), "release_meta.json");
}
async function loadReleaseMeta(spec) {
try {
const text = await readFile(releaseCachePath(spec), "utf-8");
return JSON.parse(text);
}
catch {
return null;
}
}
async function saveReleaseMeta(spec, meta) {
const p = releaseCachePath(spec);
await mkdir(dirname(p), { recursive: true });
await writeFile(p, JSON.stringify(meta, null, 2), "utf-8");
}
/**
* Fetch the release tag and asset download URL for the requested asset.
*
* Lists the repo's releases (newest first) and returns the first Release
* that actually contains `spec.assetName`. We deliberately do NOT use the
* `/releases/latest` endpoint: that returns the single newest Release for the
* whole repo, which only carries *that* release's assets. Once a repo ships
* several independent assets across different Releases (tables/story/worldview),
* `/releases/latest` makes every non-latest asset look "missing" and breaks
* their syncs. Listing + asset-name matching is robust to that layout.
*
* Return / throw contract (load-bearing — `syncRelease` distinguishes the two):
* - Returns `{tag, url}` when a Release carrying the asset is found.
* - Returns `null` when the API responded but NO scanned Release has the
* asset (genuine absence — caller must NOT retry via blind download).
* - THROWS on network/API failure (caller treats as "unreachable" and may
* fall back to blind download / cached data).
*
* This distinction matters because the blind-download fallback in `syncRelease`
* constructs a `releases/latest/download/<asset>` URL — which only makes sense
* when we genuinely couldn't reach the API, not when the API confirmed the
* asset is absent.
*/
export async function checkLatestRelease(spec, timeoutMs = 10_000) {
const url = GITHUB_RELEASES_LIST_URL.replace("{owner}", spec.owner).replace("{repo}", spec.repo);
// Network/API failure propagates as a throw (distinguished from "asset not
// found" → null below). syncRelease catches it to decide fallback behavior.
const res = await fetchCascading(url, { headers: githubHeaders() }, timeoutMs);
const data = (await res.json());
// Releases arrive newest-first; take the first carrying our asset.
// (An asset is only ever in one Release, so "first match" == "the
// Release that published it" — which is the freshest by definition.)
for (const release of data) {
const asset = release.assets.find((a) => a.name === spec.assetName);
if (asset) {
return { tag: release.tag_name, url: asset.browser_download_url };
}
}
// Defensive: if we scanned a full page (per_page releases) and still didn't
// find the asset, the asset might live in a Release beyond this page. Warn
// so a misconfigured dataset spec (or a mirror that grew past 30 releases)
// doesn't fail silently as "asset not found". sync.ts deliberately doesn't
// depend on the logger module (it returns data; startupSync logs), so this
// uses console directly — it's a rare, forward-looking guard, not a hot path.
if (data.length >= 30) {
console.warn(`[endfield-mcp/sync] Scanned ${data.length} releases (full page) for asset "${spec.assetName}" without a match — if the mirror has >30 releases, the asset may be on a later page. Consider raising per_page or paginating.`);
}
return null;
}
/**
* Download the release asset zip atomically, then write cache metadata.
* Uses write-to-tmp-then-rename for crash safety.
*/
export async function downloadReleaseAsset(spec, tag, assetUrl, timeoutMs = 120_000) {
const tmp = spec.localZip + ".tmp";
await mkdir(dirname(spec.localZip), { recursive: true });
try {
const res = await fetchCascading(assetUrl, { headers: githubHeaders(), redirect: "follow" }, timeoutMs).catch((err) => {
throw new Error(`${errorMessage(err)} downloading ${spec.assetName}`);
});
await writeFile(tmp, Buffer.from(await res.arrayBuffer()));
const missing = spec.validateZip?.(tmp) ?? [];
if (missing.length > 0) {
throw new Error(`Downloaded ${spec.assetName} is missing required entries: ${missing.join(", ")}`);
}
await rename(tmp, spec.localZip);
// Our mirror uses plain semver tags (v0.2.0) — commitSha === tag.
await saveReleaseMeta(spec, {
repo: `${spec.owner}/${spec.repo}`,
branch: "releases",
commitSha: tag,
fetchedAt: new Date().toISOString(),
files: [spec.assetName],
});
}
catch (err) {
try {
await unlink(tmp);
}
catch {
// best-effort cleanup
}
throw err;
}
}
/**
* Check latest GitHub Release and download the zip if the tag has changed.
*
* Decision tree:
* 1. Cache fresh AND zip exists → up_to_date (skip API call).
* 2. Network failure → offline_fallback (zip exists) / no_data (no zip).
* 3. Tag unchanged AND zip exists → up_to_date (refresh fetchedAt).
* 4. Tag changed or zip missing → downloadReleaseAsset → updated / fallback.
*/
export async function syncRelease(spec) {
const dummySpec = {
owner: spec.owner,
repo: spec.repo,
branch: "releases",
files: [spec.assetName],
localRoot: dirname(spec.localZip),
};
const cache = await loadReleaseMeta(spec);
const zipError = releaseZipError(spec);
const zipOk = zipError === null;
if (cache !== null && zipOk && cacheIsFresh(cache)) {
return {
spec: dummySpec,
status: "up_to_date",
commitSha: cache.commitSha,
error: null,
};
}
// checkLatestRelease throws on network/API failure, returns null only when
// the API confirmed no scanned Release carries the asset. We must treat
// these differently: a network failure warrants a blind-download fallback
// (the API might have the asset, we just couldn't reach it), whereas a
// confirmed "asset not found" does NOT (the blind URL hits the latest
// release's download path, which is exactly the wrong-release assumption
// the listing fix removed — retrying it would 404 pointlessly).
let latest;
let networkFailed = false;
try {
latest = await checkLatestRelease(spec);
}
catch {
latest = null;
networkFailed = true;
}
if (latest === null) {
if (networkFailed) {
// Network/API unreachable — fall back to cache or blind download.
if (zipOk) {
return {
spec: dummySpec,
status: "offline_fallback",
commitSha: cache?.commitSha ?? null,
error: "Network unavailable",
};
}
// No zip and API unreachable — attempt blind download via the
// releases/latest/download/ shortcut (no GitHub API call; ghproxy and
// similar mirrors support this URL pattern). This is best-effort: the
// latest release may not carry this asset (the very case the listing
// fix handles when the API IS reachable), but with the API down it's
// our only way to bootstrap a first download through a mirror.
if (mirrorUrls().length > 0) {
const blindUrl = `https://github.com/${spec.owner}/${spec.repo}/releases/latest/download/${spec.assetName}`;
try {
await downloadReleaseAsset(spec, "unknown", blindUrl);
return {
spec: dummySpec,
status: "updated",
commitSha: "unknown",
error: null,
};
}
catch (err) {
return {
spec: dummySpec,
status: "no_data",
commitSha: null,
error: errorMessage(err),
};
}
}
const error = existsSync(spec.localZip) && zipError
? `Network unavailable and no cached zip; cached zip invalid: ${zipError}`
: "Network unavailable and no cached zip";
return { spec: dummySpec, status: "no_data", commitSha: null, error };
}
// API reachable but no scanned Release carries this asset. Do NOT attempt
// the blind download (it would hit the latest release's download path —
// the exact wrong-release assumption the listing fix removes; the API just
// confirmed the asset is absent). This usually means a misconfigured
// dataset spec (wrong assetName) or a mirror that hasn't published the
// asset yet. Fall back to cache if present, else no_data.
if (zipOk) {
return {
spec: dummySpec,
status: "offline_fallback",
commitSha: cache?.commitSha ?? null,
error: `Asset "${spec.assetName}" not found in any scanned Release`,
};
}
return {
spec: dummySpec,
status: "no_data",
commitSha: null,
error: `Asset "${spec.assetName}" not found in any scanned Release and no cached zip`,
};
}
const commitSha = latest.tag;
if (cache !== null && cache.commitSha === commitSha && zipOk) {
await saveReleaseMeta(spec, {
...cache,
fetchedAt: new Date().toISOString(),
});
return { spec: dummySpec, status: "up_to_date", commitSha, error: null };
}
try {
await downloadReleaseAsset(spec, latest.tag, latest.url);
return { spec: dummySpec, status: "updated", commitSha, error: null };
}
catch (err) {
const error = errorMessage(err);
return zipOk
? {
spec: dummySpec,
status: "offline_fallback",
commitSha: cache?.commitSha ?? null,
error,
}
: { spec: dummySpec, status: "no_data", commitSha: null, error };
}
}
// ---------------------------------------------------------------------------
// Release-archive sync (download + extract)
// ---------------------------------------------------------------------------
function archiveFilesPresent(spec) {
return spec.requiredFiles.every((f) => {
const p = join(spec.localRoot, f);
return existsSync(p) && statSync(p).isFile();
});
}
function archiveMissingFiles(spec) {
return spec.requiredFiles.filter((f) => {
const p = join(spec.localRoot, f);
return !existsSync(p) || !statSync(p).isFile();
});
}
function validateArchiveZip(zipPath, requiredFiles) {
try {
const zip = new AdmZip(zipPath);
const entries = new Set(zip
.getEntries()
.filter((entry) => !entry.isDirectory)
.map((entry) => entry.entryName));
return requiredFiles.filter((file) => !entries.has(file));
}
catch (err) {
return [`${basename(zipPath)} is not a valid zip: ${errorMessage(err)}`];
}
}
async function safeExtractZip(zipPath, localRoot) {
const root = resolve(localRoot);
const zip = new AdmZip(zipPath);
const tmpPaths = [];
try {
for (const entry of zip.getEntries()) {
if (entry.isDirectory)
continue;
const dest = resolve(localRoot, entry.entryName);
const rel = relative(root, dest);
if (rel === "" ||
rel === ".." ||
rel.startsWith(`..${sep}`) ||
isAbsolute(rel)) {
throw new Error(`Unsafe zip member path: ${entry.entryName}`);
}
await mkdir(dirname(dest), { recursive: true });
const tmp = `${dest}.tmp`;
await writeFile(tmp, entry.getData());
tmpPaths.push(tmp);
await rename(tmp, dest);
tmpPaths.pop();
}
}
catch (err) {
for (const tmp of tmpPaths) {
try {
await unlink(tmp);
}
catch {
// best-effort cleanup
}
}
throw err;
}
}
/**
* Download a GitHub Release zip asset and extract it into localRoot.
*
* Keeps the data distribution path aligned with PRTS-MCP's pattern
* (release zip → local extraction → DirectoryStore reads) while preserving
* whatever on-disk layout the Endfield mirror ships.
*/
export async function syncReleaseArchive(spec) {
const releaseResult = await syncRelease({
owner: spec.owner,
repo: spec.repo,
assetName: spec.assetName,
localZip: spec.localZip,
validateZip: (zipPath) => validateArchiveZip(zipPath, spec.requiredFiles),
});
const dummySpec = {
owner: spec.owner,
repo: spec.repo,
branch: "releases",
files: spec.requiredFiles,
localRoot: spec.localRoot,
};
const filesOk = archiveFilesPresent(spec);
if (releaseResult.status === "no_data") {
return filesOk
? {
spec: dummySpec,
status: "offline_fallback",
commitSha: releaseResult.commitSha,
error: releaseResult.error,
}
: {
spec: dummySpec,
status: "no_data",
commitSha: releaseResult.commitSha,
error: releaseResult.error,
};
}
const shouldExtract = releaseResult.status === "updated" || !filesOk;
if (shouldExtract) {
try {
await safeExtractZip(spec.localZip, spec.localRoot);
}
catch (err) {
const error = errorMessage(err);
return archiveFilesPresent(spec)
? {
spec: dummySpec,
status: "offline_fallback",
commitSha: releaseResult.commitSha,
error,
}
: {
spec: dummySpec,
status: "no_data",
commitSha: releaseResult.commitSha,
error,
};
}
const missing = archiveMissingFiles(spec);
if (missing.length > 0) {
const error = `Archive extraction missing required files: ${missing.slice(0, 10).join("; ")}`;
return archiveFilesPresent(spec)
? {
spec: dummySpec,
status: "offline_fallback",
commitSha: releaseResult.commitSha,
error,
}
: {
spec: dummySpec,
status: "no_data",
commitSha: releaseResult.commitSha,
error,
};
}
}
return {
spec: dummySpec,
status: releaseResult.status,
commitSha: releaseResult.commitSha,
error: releaseResult.error,
};
}
//# sourceMappingURL=sync.js.map