UNPKG

endfield-mcp

Version:

MCP Server for Arknights: Endfield (TypeScript / stdio + Streamable HTTP)

269 lines • 9.79 kB
/** * JSON store abstraction for local dataset access. * * Mirrors PRTS-MCP's `ts/src/data/stores.ts` shape-for-shape so the data * layer stays portable across the two projects. Business code only ever * touches the {@link JsonStore} interface; whether a file lives in a real * directory, inside a zip, or in a bundled fallback is invisible to callers. * * The same path-safety guarantees are preserved: * - backslashes normalized to forward slashes * - no leading "/" * - no ".." segments * - resolved target must remain inside the store root (DirectoryStore) */ import { existsSync, readFileSync, statSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; import AdmZip from "adm-zip"; /** * Pre-process JSON text so int64-sized integers are parsed as strings. * * Endfield localization ids (e.g. `-7078064683023630592`) exceed JS * Number.MAX_SAFE_INTEGER (2^53-1). A plain `JSON.parse` silently * truncates them, breaking i18n lookups. This wraps any bare integer * literal whose absolute value exceeds MAX_SAFE_INTEGER in double quotes * so it survives parsing as a string. * * ## Correctness properties (learned the hard way — see CR #1) * * 1. **String-aware**: numbers appearing inside JSON string values must * NOT be quoted — they're already characters, not values. A naive * regex that ignores string context corrupts tables whose free-text * fields (CV bios, lore) happen to contain `", "` followed by a long * digit run. * * 2. **Unbounded digit length**: capping at 19 digits (int64 max) leaves * 20+ digit literals half-quoted, producing invalid JSON. Any length * is allowed; BigInt handles arbitrary precision. * * 3. **Float-safe**: a number like `9.2e18` or `9007199254740999.5` is * a float, not an int — quoting its integer part produces invalid * JSON. We skip any number immediately followed by `.`, `e`, or `E`. * * The implementation scans the text once, tracking whether the cursor is * inside a string. String parsing handles `\"` escapes and is the only * way to be correct without a full JSON tokenizer. */ function parseInt64Safe(text) { // We build the output character-by-character. This is O(n) and allocates // one string; the alternative (regex with string-skip) can't be made // correct without variable-width lookbehind, which JS doesn't support. let out = ""; let i = 0; const len = text.length; while (i < len) { const ch = text[i]; // String literal: copy verbatim until the closing unescaped quote. if (ch === '"') { out += ch; i++; while (i < len) { const s = text[i]; out += s; i++; if (s === "\\") { // Escaped char — copy the next char too, whatever it is. if (i < len) { out += text[i]; i++; } } else if (s === '"') { break; } } continue; } // Potential number: starts with digit or sign+digit, in a value // position (we're not inside a string, and the preceding non-ws char // is one of `:`, `,`, `[`, `{`, or start-of-text). if (ch === "-" || ch === "+" || (ch >= "0" && ch <= "9")) { // Confirm this is a value position, not e.g. a bare `-` in text. // Walk back past whitespace to find the structural char. let j = i - 1; while (j >= 0 && (text[j] === " " || text[j] === "\t" || text[j] === "\n" || text[j] === "\r")) { j--; } const structural = j < 0 ? "{" : text[j]; // start-of-text acts like object start if (structural === ":" || structural === "," || structural === "[" || structural === "{") { // Capture the full numeric token: sign + digits. let numStart = i; if (text[i] === "-" || text[i] === "+") i++; const digitStart = i; while (i < len && text[i] >= "0" && text[i] <= "9") i++; const digitCount = i - digitStart; // Skip floats entirely: a `.` or exponent means this isn't an int. if (i < len && (text[i] === "." || text[i] === "e" || text[i] === "E")) { out += text.substring(numStart, i); continue; // let the main loop handle the fractional part } if (digitCount >= 15) { const digits = text.substring(numStart, i); const n = BigInt(digits); if (n > BigInt(Number.MAX_SAFE_INTEGER) || n < BigInt(-Number.MAX_SAFE_INTEGER)) { out += `"${digits}"`; continue; } } out += text.substring(numStart, i); continue; } } out += ch; i++; } return out; } function normalizePath(path) { const normalized = path.replace(/\\/g, "/"); if (normalized.startsWith("/")) { throw new Error(`Unsafe dataset path: ${path}`); } const parts = normalized.split("/").filter((part) => part !== "" && part !== "."); if (parts.includes("..")) { throw new Error(`Unsafe dataset path: ${path}`); } return parts.join("/"); } function isInsideRoot(root, target) { const rel = relative(root, target); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } // --------------------------------------------------------------------------- // DirectoryStore // --------------------------------------------------------------------------- export class DirectoryStore { root; constructor(root) { this.root = root; } resolvePath(path) { const root = resolve(this.root); const target = resolve(this.root, normalizePath(path)); if (!isInsideRoot(root, target)) { throw new Error(`Unsafe dataset path: ${path}`); } return target; } resolveForDiagnostics(path) { return this.resolvePath(path); } exists(path) { const target = this.resolvePath(path); return existsSync(target) && statSync(target).isFile(); } readText(path) { const target = this.resolvePath(path); if (!existsSync(target) || !statSync(target).isFile()) { throw new Error(`Dataset file not found: ${path}`); } return readFileSync(target, "utf-8"); } readJson(path) { return JSON.parse(this.readText(path)); } readJsonInt64Safe(path) { return JSON.parse(parseInt64Safe(this.readText(path))); } describe() { return `directory:${this.root}`; } close() { // DirectoryStore does not hold open resources. } } // --------------------------------------------------------------------------- // ZipStore // --------------------------------------------------------------------------- export class ZipStore { zipPath; _zip = null; constructor(zipPath) { this.zipPath = zipPath; } zip() { if (this._zip === null) { this._zip = new AdmZip(this.zipPath); } return this._zip; } close() { this._zip = null; } exists(path) { const innerPath = normalizePath(path); if (!existsSync(this.zipPath)) return false; return this.zip().getEntry(innerPath) !== null; } readText(path) { const innerPath = normalizePath(path); const entry = this.zip().getEntry(innerPath); if (!entry) { throw new Error(`Dataset zip entry not found: ${path}`); } return entry.getData().toString("utf-8"); } readJson(path) { return JSON.parse(this.readText(path)); } readJsonInt64Safe(path) { return JSON.parse(parseInt64Safe(this.readText(path))); } describe() { return `zip:${this.zipPath}`; } } // --------------------------------------------------------------------------- // FallbackStore // --------------------------------------------------------------------------- export class FallbackStore { primary; fallback; constructor(primary, fallback) { this.primary = primary; this.fallback = fallback; } storeFor(path) { if (this.primary.exists(path)) return this.primary; if (this.fallback.exists(path)) return this.fallback; return null; } exists(path) { return this.storeFor(path) !== null; } readText(path) { const store = this.storeFor(path); if (store === null) { throw new Error(`Dataset file not found in fallback chain: ${path}`); } return store.readText(path); } readJson(path) { const store = this.storeFor(path); if (store === null) { throw new Error(`Dataset file not found in fallback chain: ${path}`); } return store.readJson(path); } readJsonInt64Safe(path) { const store = this.storeFor(path); if (store === null) { throw new Error(`Dataset file not found in fallback chain: ${path}`); } return store.readJsonInt64Safe(path); } describe() { return `fallback:${this.primary.describe()} -> ${this.fallback.describe()}`; } close() { this.primary.close(); this.fallback.close(); } } //# sourceMappingURL=stores.js.map