endfield-mcp
Version:
MCP Server for Arknights: Endfield (TypeScript / stdio + Streamable HTTP)
151 lines • 8.3 kB
JavaScript
/**
* Endfield-MCP server entry point.
*
* Creates the McpServer instance, binds the wiki client to the loaded
* config, registers tools, then dispatches to the transport selected by
* EF_TRANSPORT:
*
* stdio (default) — for local Claude Desktop / Claude Code / Chatbox
* http — stateless Streamable HTTP via Bun.serve (remote use)
*
* One TS implementation covers both transports (the historical reason
* PRTS-MCP needed a second TS implementation alongside Python — asyncio
* friction with Streamable HTTP — does not apply when the runtime is
* already Bun/TS end-to-end).
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { existsSync } from "node:fs";
import { join } from "node:path";
import pkg from "../package.json" with { type: "json" };
import { loadConfig } from "./config.js";
import { createLogger } from "./utils/log.js";
import { bindWikiConfig } from "./api/endfieldWiki.js";
import { registerWikiTools } from "./tools/wikiTools.js";
import { registerGamedataTools } from "./tools/gamedataTools.js";
import { registerStoryTools } from "./tools/storyTools.js";
import { registerWorldviewTools } from "./tools/worldviewTools.js";
import { bindCharacterStore } from "./data/characters.js";
import { bindTextStore } from "./data/texts.js";
import { bindStoryStore } from "./data/story.js";
import { bindWorldviewStore } from "./data/worldview.js";
import { DirectoryStore, FallbackStore } from "./data/stores.js";
import { runStartupSync } from "./startupSync.js";
import { runStdio } from "./transports/stdio.js";
import { runHttp } from "./transports/http.js";
// ---------------------------------------------------------------------------
// Logging + version
// ---------------------------------------------------------------------------
const SERVER_NAME = "Endfield_Wiki_Assistant";
// Single source of truth: read version from package.json rather than a
// hand-maintained string literal. A literal drifts out of sync on release
// (v0.3.2 shipped with the server reporting "0.3.1" because this constant
// wasn't bumped alongside package.json — v0.3.3 fixes that).
const SERVER_VERSION = pkg.version;
const log = createLogger("ef.server");
// ---------------------------------------------------------------------------
// MCP Server factory
// ---------------------------------------------------------------------------
/**
* Build a configured McpServer with all tools registered.
*
* Wiki client binding happens once per process at startup; the client
* reads its endpoint/UA/Referer from that binding, so per-request server
* instances (HTTP transport creates one per request) all share the same
* wiki config snapshot.
*/
function createMcpServer() {
const server = new McpServer({
name: SERVER_NAME,
version: SERVER_VERSION,
});
registerWikiTools(server);
registerGamedataTools(server);
registerStoryTools(server);
registerWorldviewTools(server);
return server;
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
async function main() {
const cfg = loadConfig();
bindWikiConfig(cfg);
// Build the GameData store as a two-layer FallbackStore:
// primary = auto-sync directory (cfg.dataPath, freshest when present)
// fallback = bundled directory (cfg.bundledDataPath, ships with the
// npm package / Docker image, may be slightly stale)
//
// We always construct the FallbackStore (never a bare DirectoryStore)
// because the synced directory may not exist at startup but can be
// populated by a background sync moments later. DirectoryStore.exists()
// naturally returns false for a missing directory, so FallbackStore
// transparently falls through to bundled until sync writes files —
// and then automatically picks up the synced files on the next read
// (after cache clearing). A startup-time branch that binds a bare
// DirectoryStore(bundled) when synced is absent would permanently miss
// the later-synced data.
const syncedExists = existsSync(cfg.dataPath);
const bundledExists = existsSync(cfg.bundledDataPath);
const dataStore = new FallbackStore(new DirectoryStore(cfg.dataPath), new DirectoryStore(cfg.bundledDataPath));
log("INFO", `GameData store: FallbackStore(synced=${cfg.dataPath}${syncedExists ? "" : " [absent]"}, bundled=${cfg.bundledDataPath}${bundledExists ? "" : " [absent]"})`);
if (!syncedExists && !bundledExists) {
log("WARN", `No GameData available yet — both layers absent. GameData tools will report "no data" until the mirror is synced or bundled data is populated.`);
}
// Text resolver must bind before character reader — the reader calls
// resolveText() during projections, which needs the i18n index loaded.
bindTextStore(dataStore);
bindCharacterStore(dataStore);
// Story store: always construct FallbackStore unconditionally (matching
// the GameData pattern above). The synced/story directory may not exist
// at startup but gets populated by background sync — DirectoryStore
// returns false from exists() for missing dirs, so FallbackStore
// transparently falls through until sync writes files. Gating the
// binding on directory existence would permanently miss later-synced
// data (the exact trap the GameData path was rewritten to avoid).
const storySynced = join(cfg.dataPath, "story");
const storyBundled = join(cfg.bundledDataPath, "story");
const storySyncedExists = existsSync(storySynced);
const storyBundledExists = existsSync(storyBundled);
const storyStore = new FallbackStore(new DirectoryStore(storySynced), new DirectoryStore(storyBundled));
bindStoryStore(storyStore);
log("INFO", `Story store: FallbackStore(synced=${storySynced}${storySyncedExists ? "" : " [absent]"}, bundled=${storyBundled}${storyBundledExists ? "" : " [absent]"})`);
if (!storySyncedExists && !storyBundledExists) {
log("INFO", `Story data not yet available — both layers absent. Story tools will report "no data" until the v0.3.0 mirror sync completes.`);
}
// Worldview store: same unconditional FallbackStore pattern as story.
// The worldview bundle (endfield-worldview.zip, v0.4) holds the in-game
// PRTS archive + encyclopedia. Note this store also resolves PRTS body
// text via the tables bundle's i18n (bound above), so worldview depends
// on bindTextStore having run — ordering is correct here.
const worldviewSynced = join(cfg.dataPath, "worldview");
const worldviewBundled = join(cfg.bundledDataPath, "worldview");
const worldviewSyncedExists = existsSync(worldviewSynced);
const worldviewBundledExists = existsSync(worldviewBundled);
const worldviewStore = new FallbackStore(new DirectoryStore(worldviewSynced), new DirectoryStore(worldviewBundled));
bindWorldviewStore(worldviewStore);
log("INFO", `Worldview store: FallbackStore(synced=${worldviewSynced}${worldviewSyncedExists ? "" : " [absent]"}, bundled=${worldviewBundled}${worldviewBundledExists ? "" : " [absent]"})`);
if (!worldviewSyncedExists && !worldviewBundledExists) {
log("INFO", `Worldview data not yet available — both layers absent. Worldview tools will report "no data" until the v0.4.0 mirror sync completes.`);
}
log("INFO", `Endfield-MCP ${SERVER_VERSION} starting (transport=${cfg.transport}, wiki=${cfg.wikiEndpoint})`);
// Fire-and-forget. In v0.1 this is a no-op; in v0.2+ the background
// thread handles mirror sync without blocking server startup.
void runStartupSync().catch((err) => {
log("ERROR", `Startup sync threw unexpectedly: ${err instanceof Error ? err.message : String(err)}`);
});
if (cfg.transport === "http") {
await runHttp(createMcpServer, {
port: cfg.httpPort,
host: cfg.httpHost,
});
}
else {
await runStdio(createMcpServer());
}
}
main().catch((err) => {
log("ERROR", `Fatal: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
//# sourceMappingURL=server.js.map