This commit is contained in:
2026-09-10 17:03:10 +02:00
parent 90dc18a167
commit 3a3d70efeb
27 changed files with 4973 additions and 3 deletions
+499
View File
@@ -0,0 +1,499 @@
/**
* Minimal MCP (Model Context Protocol) stdio server exposing the RA3 Mod XML
* agent snapshot query API.
*
* This is intentionally dependency-free. It speaks the JSON-RPC-over-stdio
* subset used by MCP clients:
*
* initialize
* notifications/initialized
* ping
* tools/list
* tools/call
*
* Usage:
* node out/agent/mcpServer.js --project D:/Mods/Example
* node out/agent/mcpServer.js --snapshot /path/to/snapshot.json.gz
*/
import { createInterface } from "node:readline";
import {
isProcessAlive,
readEndpoint,
readEndpointForProject,
} from "./endpoint";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import {
findAssets,
findDefine,
findReferenceGroups,
isFileActive,
listAssetsByType,
resolveIncludeSource,
statusFromSnapshot,
} from "./query";
import type { AgentIndexSnapshot } from "./types";
interface McpTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
handler: (args: Record<string, unknown>, snapshot: AgentIndexSnapshot | null) => unknown;
}
const USAGE_GUIDE = `RA3 Mod XML index query tools
This MCP server exposes the semantic index built by the RA3 Mod XML VS Code extension.
Use these tools instead of full-text grepping the XML tree when you need exact facts:
- find_asset(id, type?) -> definition sites (file/line/origin/stream)
- find_references(id, type?) -> semantic reference sites
- get_asset_references(id, type?, depth?, targetTypes?) -> outgoing reference EDGES with element context
- list_assets_by_type(type, prefix?, limit?) -> assets of a type
- is_file_active(path) -> whether a file is part of an indexed include stream
- find_define(name) -> $DEFINE definitions
- resolve_include(source) -> candidate source file
- get_status() -> current index state
Tips:
- Asset ids are case-insensitive.
- Prefer passing type when the same id exists for multiple asset types.
- Always check the returned index state; if it is stale/incomplete, treat results as provisional.
- Use get_asset_references to follow "which weapon/model/upgrade does this asset use" chains.
It returns edges annotated with the element name, parent element and attribute that produced
them, plus the exact XML file/line, so you do not have to read source to find the link.
Start with depth 1 (the default) and pass targetTypes (e.g. ["WeaponTemplate"]) to cut noise.
Edges with a "definedIn" field come from an inheritFrom ancestor's XML.
When "truncated" is true, read "omittedByTargetType" and narrow the query instead of retrying.
- Do not attempt to read the entire snapshot file; query narrowly.`;
const TOOLS: McpTool[] = [
{
name: "get_status",
description: "Returns the current index state and basic statistics.",
inputSchema: { type: "object", properties: {} },
handler: (_args, snapshot) => statusFromSnapshot(snapshot),
},
{
name: "find_asset",
description: "Finds asset definitions by id, optionally filtered by asset type.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id to find" },
type: { type: "string", description: "Optional asset type filter" },
},
required: ["id"],
},
handler: (args, snapshot) => {
const id = String(args.id ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findAssets(snapshot, id, args.type ? String(args.type) : null),
};
},
},
{
name: "find_references",
description: "Finds semantic reference sites pointing to an asset id, optionally filtered by type.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id whose references to find" },
type: { type: "string", description: "Optional asset type filter" },
},
required: ["id"],
},
handler: (args, snapshot) => {
const id = String(args.id ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findReferenceGroups(snapshot, id, args.type ? String(args.type) : null),
};
},
},
{
name: "list_assets_by_type",
description: "Lists asset definitions of one type, optionally filtered by id prefix.",
inputSchema: {
type: "object",
properties: {
type: { type: "string", description: "Asset type" },
prefix: { type: "string", description: "Optional id prefix" },
limit: { type: "number", description: "Maximum number of results" },
},
required: ["type"],
},
handler: (args, snapshot) => {
const type = String(args.type ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
const limit = typeof args.limit === "number" ? args.limit : undefined;
return {
index: statusFromSnapshot(snapshot),
data: listAssetsByType(snapshot, type, args.prefix ? String(args.prefix) : "", limit),
};
},
},
{
name: "is_file_active",
description: "Returns whether a file belongs to an indexed include stream (i.e. is not a dead file).",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Absolute file path" },
},
required: ["path"],
},
handler: (args, snapshot) => {
const path = String(args.path ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: { active: isFileActive(snapshot, path) },
};
},
},
{
name: "find_define",
description: "Finds $DEFINE constants by name.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Define name (with or without leading $)" },
},
required: ["name"],
},
handler: (args, snapshot) => {
const name = String(args.name ?? "").replace(/^\$/, "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: findDefine(snapshot, name),
};
},
},
{
name: "resolve_include",
description: "Resolves an Include source string from the snapshot's candidate list.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Include source, e.g. DATA:Units/Example.xml" },
},
required: ["source"],
},
handler: (args, snapshot) => {
const source = String(args.source ?? "");
if (!snapshot) return statusFromSnapshot(snapshot);
return {
index: statusFromSnapshot(snapshot),
data: resolveIncludeSource(snapshot, source),
};
},
},
{
name: "get_asset_references",
description:
"Returns the outgoing references (edges) of an asset: which assets it references, through which element/attribute, and at which file/line. Also follows inheritFrom ancestors (marked with definedIn). Live index required.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Asset id whose outgoing references to return" },
type: { type: "string", description: "Optional asset type filter" },
depth: {
type: "number",
description:
"Levels of assets to expand: 1 (default) = the asset itself, including inherited XML; max 3.",
},
targetTypes: {
type: "array",
items: { type: "string" },
description:
"Only keep edges whose target is assignable to one of these types, e.g. [\"WeaponTemplate\"].",
},
maxEdges: { type: "number", description: "Hard cap on returned edges (default 200)." },
includeUnresolved: {
type: "boolean",
description: "Also return edges whose reference value could not be resolved.",
},
},
required: ["id"],
},
// Live-only: element context is not stored in the on-disk snapshot.
handler: () => ({
index: { state: "no_index" },
error:
"get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.",
}),
},
{
name: "get_usage_guide",
description: "Returns guidance for using the RA3 Mod XML index tools.",
inputSchema: { type: "object", properties: {} },
handler: () => ({ text: USAGE_GUIDE }),
},
];
/** Tools that can only be answered by the live extension server. */
const LIVE_ONLY_TOOLS = new Set(["get_asset_references"]);
export function liveUrlForTool(
endpointUrl: string,
projectDir: string | null,
toolName: string,
args: Record<string, unknown>,
): string | null {
const base = endpointUrl.replace(/\/$/, "");
const q = new URLSearchParams();
// Always pin the requested project. Without this the server would silently
// answer from whatever project its active editor points at.
if (projectDir) q.set("project", projectDir);
switch (toolName) { case "get_status":
return q.toString() ? `${base}/status?${q}` : `${base}/status`;
case "find_asset":
q.set("id", String(args.id ?? ""));
if (args.type != null) q.set("type", String(args.type));
return `${base}/find_asset?${q}`;
case "find_references":
q.set("id", String(args.id ?? ""));
if (args.type != null) q.set("type", String(args.type));
return `${base}/find_references?${q}`;
case "get_asset_references":
q.set("id", String(args.id ?? ""));
if (args.type != null) q.set("type", String(args.type));
if (args.depth != null) q.set("depth", String(args.depth));
if (Array.isArray(args.targetTypes)) {
q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(","));
}
if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges));
if (args.includeUnresolved != null) {
q.set("includeUnresolved", String(args.includeUnresolved));
}
return `${base}/get_asset_references?${q}`;
case "list_assets_by_type":
q.set("type", String(args.type ?? ""));
if (args.prefix != null) q.set("prefix", String(args.prefix));
if (args.limit != null) q.set("limit", String(args.limit));
return `${base}/list_assets?${q}`;
case "is_file_active":
q.set("path", String(args.path ?? ""));
return `${base}/is_file_active?${q}`;
case "find_define":
q.set("name", String(args.name ?? ""));
return `${base}/find_define?${q}`;
case "resolve_include":
q.set("source", String(args.source ?? ""));
return `${base}/resolve_include?${q}`;
default:
return null;
}
}
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
}
/**
* Rejects a live response that belongs to a different project than the one
* this MCP client was started for. This is the guard against the
* multi-window cross-talk described in docs/ai-agent-integration-plan.md §10:
* returning another project's data silently would be worse than returning
* nothing, because the agent cannot tell the difference.
*/
export function responseProjectMismatch(
payload: unknown,
projectDir: string | null,
): boolean {
if (!projectDir) return false;
const index = (payload as { index?: { projectDir?: string } } | null)?.index;
const reported = index?.projectDir;
if (!reported) return false;
return normalizePath(reported) !== normalizePath(projectDir);
}
/** Cooldown after a failed live attempt, to avoid a probe per tool call. */
const LIVE_RETRY_COOLDOWN_MS = 5000;
let liveUnavailableUntil = 0;
interface LiveResult {
payload: unknown;
/** True when the live server answered but for a different project. */
mismatched: boolean;
}
/** Tries the live extension server; returns null when unavailable. */
async function tryLiveQuery(
toolName: string,
args: Record<string, unknown>,
projectDir: string | null,
agentHome?: string,
): Promise<LiveResult | null> {
if (Date.now() < liveUnavailableUntil) return null;
// Prefer the per-project endpoint so two open windows cannot shadow each
// other; fall back to the legacy global file only when it matches.
let endpoint = projectDir
? await readEndpointForProject(projectDir, agentHome)
: null;
if (!endpoint) {
const fallback = await readEndpoint(agentHome);
if (
fallback &&
(!projectDir ||
!fallback.projectDir ||
normalizePath(fallback.projectDir) === normalizePath(projectDir))
) {
endpoint = fallback;
}
}
if (!endpoint) return null;
// A crashed VS Code can leave the file behind; a dead PID means stale.
if (!isProcessAlive(endpoint.processId)) return null;
const url = liveUrlForTool(endpoint.url, projectDir, toolName, args);
if (!url) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const res = await fetch(url, {
headers: { authorization: `Bearer ${endpoint.token}` },
signal: controller.signal,
});
if (!res.ok) {
if (res.status === 401 || res.status === 404) return null;
return null;
}
const payload: unknown = await res.json();
return { payload, mismatched: responseProjectMismatch(payload, projectDir) };
} catch {
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS;
return null;
} finally {
clearTimeout(timeout);
}
}
function sendMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function resultFor(id: unknown, result: unknown): unknown {
return { jsonrpc: "2.0", id, result };
}
function errorFor(id: unknown, code: number, message: string): unknown {
return { jsonrpc: "2.0", id, error: { code, message } };
}
async function handleRequest(
message: Record<string, unknown>,
snapshot: AgentIndexSnapshot | null,
projectDir: string | null,
agentHome?: string,
): Promise<unknown | null> {
const method = String(message.method ?? "");
const id = message.id;
const params = (message.params ?? {}) as Record<string, unknown>;
switch (method) {
case "initialize":
return resultFor(id, {
protocolVersion: params.protocolVersion ?? "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "ra3-mod-xml", version: "0.1.0" },
});
case "ping":
return resultFor(id, {});
case "tools/list":
return resultFor(id, {
tools: TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
});
case "tools/call": {
const toolName = String(params.name ?? "");
const tool = TOOLS.find((t) => t.name === toolName);
if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`);
const args = (params.arguments ?? {}) as Record<string, unknown>;
const live = await tryLiveQuery(toolName, args, projectDir, agentHome);
if (live?.mismatched) {
// The server answered for another project. Refuse it: a plausible
// wrong answer is worse than an explicit failure.
liveUnavailableUntil = Date.now() + LIVE_RETRY_COOLDOWN_MS;
return textResult(id, {
index: { state: "error", projectDir: projectDir ?? undefined },
error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`,
});
}
if (LIVE_ONLY_TOOLS.has(toolName)) {
// Report a clear reason instead of an empty result, so the agent does
// not conclude "this asset has no references".
return textResult(
id,
live?.payload ?? {
index: { state: "no_index", projectDir: projectDir ?? undefined },
error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`,
},
);
}
const output = live?.payload ?? tool.handler(args, snapshot);
return textResult(id, output);
}
default:
// Notifications have no id; ignore them.
if (id === undefined) return null;
return errorFor(id, -32601, `Method not found: ${method}`);
}
}
/** Wraps any tool payload into an MCP text content result. */
function textResult(id: unknown, payload: unknown): unknown {
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
return resultFor(id, { content: [{ type: "text", text }] });
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
let projectDir: string | null = null;
let snapshotPath: string | null = null;
let agentHome: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--project" || args[i] === "-p") projectDir = args[++i] ?? null;
else if (args[i] === "--snapshot" || args[i] === "-s") snapshotPath = args[++i] ?? null;
else if (args[i] === "--agent-home") agentHome = args[++i] ?? undefined;
}
const resolvedSnapshotPath =
snapshotPath ??
(projectDir ? snapshotPathForProject(projectDir, agentHome) : null);
let snapshot: AgentIndexSnapshot | null = null;
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath);
const rl = createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
rl.on("line", (line) => {
if (!line.trim()) return;
let message: Record<string, unknown>;
try {
message = JSON.parse(line) as Record<string, unknown>;
} catch {
return;
}
void handleRequest(message, snapshot, projectDir, agentHome).then((response) => {
if (response != null) sendMessage(response);
});
});
}
// Only run the stdio loop when executed directly, so the module stays
// importable by tests.
if (typeof require !== "undefined" && require.main === module) {
void main();
}