This commit is contained in:
2026-09-10 19:18:15 +02:00
parent 3a3d70efeb
commit 5993da4ce6
21 changed files with 2916 additions and 340 deletions
+28 -148
View File
@@ -17,11 +17,8 @@
*/
import { createInterface } from "node:readline";
import {
isProcessAlive,
readEndpoint,
readEndpointForProject,
} from "./endpoint";
import { LiveClient } from "./liveClient";
import { pruneInstances } from "./instances";
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
import {
findAssets,
@@ -53,6 +50,7 @@ Use these tools instead of full-text grepping the XML tree when you need exact f
- 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
- list_projects() -> project roots the live extension has indexed
- get_status() -> current index state
Tips:
@@ -228,6 +226,17 @@ const TOOLS: McpTool[] = [
"get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.",
}),
},
{
name: "list_projects",
description:
"Lists the project roots the live extension currently has indexed. Use it to discover which projects this server can answer for.",
inputSchema: { type: "object", properties: {} },
handler: () => ({
index: { state: "no_index" },
error:
"list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
}),
},
{
name: "get_usage_guide",
description: "Returns guidance for using the RA3 Mod XML index tools.",
@@ -237,142 +246,7 @@ const TOOLS: McpTool[] = [
];
/** 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);
}
}
const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
function sendMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
@@ -390,7 +264,7 @@ async function handleRequest(
message: Record<string, unknown>,
snapshot: AgentIndexSnapshot | null,
projectDir: string | null,
agentHome?: string,
live: LiveClient,
): Promise<unknown | null> {
const method = String(message.method ?? "");
const id = message.id;
@@ -418,12 +292,12 @@ async function handleRequest(
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);
const result = await live.query(toolName, args);
if (live?.mismatched) {
if (result?.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;
live.markUnavailable();
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.`,
@@ -435,14 +309,14 @@ async function handleRequest(
// not conclude "this asset has no references".
return textResult(
id,
live?.payload ?? {
result?.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);
const output = result?.payload ?? tool.handler(args, snapshot);
return textResult(id, output);
}
default:
@@ -474,6 +348,12 @@ async function main(): Promise<void> {
let snapshot: AgentIndexSnapshot | null = null;
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath);
// One-shot crash cleanup: a window that died without disposing leaves its
// instance file behind, and whichever instance starts next prunes it.
void pruneInstances(agentHome).catch(() => undefined);
const live = new LiveClient({ projectDir, agentHome });
const rl = createInterface({
input: process.stdin,
crlfDelay: Infinity,
@@ -486,7 +366,7 @@ async function main(): Promise<void> {
} catch {
return;
}
void handleRequest(message, snapshot, projectDir, agentHome).then((response) => {
void handleRequest(message, snapshot, projectDir, live).then((response) => {
if (response != null) sendMessage(response);
});
});