cwtools-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import type { LspHost } from 'cwtools-shared';
2
+ export interface LspProcessHostOptions {
3
+ workspaceRoot: string;
4
+ game?: string;
5
+ serverPath?: string;
6
+ rulesCache?: string;
7
+ cachePath?: string;
8
+ gamePath?: string;
9
+ bundledRulesPath?: string;
10
+ }
11
+ export declare function resolveRulesCacheRoot(options: {
12
+ rulesCache?: string;
13
+ cachePath?: string;
14
+ workspaceRoot: string;
15
+ }): string;
16
+ export declare class LspProcessHost implements LspHost {
17
+ private readonly options;
18
+ private process?;
19
+ private connection?;
20
+ private startPromise?;
21
+ private startError?;
22
+ private startedAtMs?;
23
+ private fileWatcher?;
24
+ private watcherFlushTimer?;
25
+ private readonly pendingWatchedChanges;
26
+ constructor(options: LspProcessHostOptions);
27
+ get readyAtMs(): number | undefined;
28
+ executeCommand<T = unknown>(command: string, args?: unknown[], options?: {
29
+ timeoutMs?: number;
30
+ }): Promise<T>;
31
+ sendRequest<T = unknown>(method: string, params: unknown, timeoutMs?: number): Promise<T>;
32
+ request<T = unknown>(method: string, params?: unknown, options?: {
33
+ timeoutMs?: number;
34
+ }): Promise<T>;
35
+ dispose(): void;
36
+ private ensureStarted;
37
+ private start;
38
+ private startFileWatcher;
39
+ private stopFileWatcher;
40
+ private queueWatchedFileChange;
41
+ private flushWatchedFileChanges;
42
+ private unavailable;
43
+ private withTimeout;
44
+ private waitForExecuteCommandsReady;
45
+ }
46
+ export declare function createLspProcessHost(options: LspProcessHostOptions): LspProcessHost;
47
+ export declare function resolveDefaultServerPath(): string | undefined;
48
+ export declare function pathToFileUri(filePath: string): string;
49
+ export declare function isLspWatchedFile(workspaceRoot: string, filePath: string, game?: string): boolean;
@@ -0,0 +1,412 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.LspProcessHost = void 0;
37
+ exports.resolveRulesCacheRoot = resolveRulesCacheRoot;
38
+ exports.createLspProcessHost = createLspProcessHost;
39
+ exports.resolveDefaultServerPath = resolveDefaultServerPath;
40
+ exports.pathToFileUri = pathToFileUri;
41
+ exports.isLspWatchedFile = isLspWatchedFile;
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ const child_process_1 = require("child_process");
46
+ const chokidar_1 = require("chokidar");
47
+ const node_1 = require("vscode-jsonrpc/node");
48
+ const vscodeCache_1 = require("./vscodeCache");
49
+ const projectSettings_1 = require("./projectSettings");
50
+ // The rules-cache root (also where the server reads/writes <game>.cwb). Kept as a
51
+ // shared helper so the vanilla-cache probe resolves the exact same location.
52
+ function resolveRulesCacheRoot(options) {
53
+ return options.rulesCache ?? options.cachePath ?? path.join(options.workspaceRoot, '.cwtools', 'rules-cache');
54
+ }
55
+ class LspProcessHost {
56
+ constructor(options) {
57
+ this.options = options;
58
+ this.pendingWatchedChanges = new Map();
59
+ }
60
+ get readyAtMs() {
61
+ return this.startedAtMs;
62
+ }
63
+ async executeCommand(command, args = [], options) {
64
+ try {
65
+ await this.ensureStarted(options?.timeoutMs);
66
+ const result = await this.withTimeout(this.connection.sendRequest('workspace/executeCommand', {
67
+ command,
68
+ arguments: args,
69
+ }), options?.timeoutMs ?? 20000, `LSP command ${command} timed out`);
70
+ return result;
71
+ }
72
+ catch (error) {
73
+ return this.unavailable(error instanceof Error ? error.message : String(error));
74
+ }
75
+ }
76
+ async sendRequest(method, params, timeoutMs = 20000) {
77
+ try {
78
+ await this.ensureStarted(timeoutMs);
79
+ return await this.withTimeout(this.connection.sendRequest(method, params), timeoutMs, `LSP request ${method} timed out`);
80
+ }
81
+ catch (error) {
82
+ return this.unavailable(error instanceof Error ? error.message : String(error));
83
+ }
84
+ }
85
+ async request(method, params, options) {
86
+ return this.sendRequest(method, params, options?.timeoutMs);
87
+ }
88
+ dispose() {
89
+ const proc = this.process;
90
+ const connection = this.connection;
91
+ // Null fields first so a second dispose() (e.g. from the process 'exit'
92
+ // safety net) is a no-op and never double-kills.
93
+ this.connection = undefined;
94
+ this.process = undefined;
95
+ this.startPromise = undefined;
96
+ this.stopFileWatcher();
97
+ try {
98
+ connection?.dispose();
99
+ }
100
+ catch {
101
+ // ignore disposal failures
102
+ }
103
+ // Kill only the child WE spawned. On its stdin closing the F# server also
104
+ // self-exits on EOF, but an explicit kill is the hard guarantee.
105
+ try {
106
+ proc?.kill();
107
+ }
108
+ catch {
109
+ // process may already be gone
110
+ }
111
+ }
112
+ async ensureStarted(timeoutMs = 30000) {
113
+ if (this.connection && !this.startError)
114
+ return;
115
+ if (!this.startPromise) {
116
+ this.startPromise = this.start();
117
+ }
118
+ await this.withTimeout(this.startPromise, timeoutMs, 'CWTools LSP startup timed out');
119
+ if (this.startError)
120
+ throw new Error(this.startError);
121
+ }
122
+ async start() {
123
+ const serverPath = this.options.serverPath ?? resolveDefaultServerPath();
124
+ if (!serverPath || !fs.existsSync(serverPath)) {
125
+ this.startError = `CWTools server binary was not found. Checked: ${serverPath ?? '(none)'}`;
126
+ throw new Error(this.startError);
127
+ }
128
+ this.process = (0, child_process_1.spawn)(serverPath, [], {
129
+ cwd: this.options.workspaceRoot,
130
+ stdio: ['pipe', 'pipe', 'pipe'],
131
+ windowsHide: true,
132
+ });
133
+ this.process.stderr.on('data', chunk => {
134
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
135
+ if (text.trim()) {
136
+ process.stderr.write(`[cwtools-lsp] ${text}`);
137
+ }
138
+ });
139
+ this.process.on('exit', (code, signal) => {
140
+ this.startError = `CWTools LSP exited with code ${code ?? 'null'} signal ${signal ?? 'null'}`;
141
+ this.connection = undefined;
142
+ this.stopFileWatcher();
143
+ });
144
+ this.connection = (0, node_1.createMessageConnection)(new node_1.StreamMessageReader(this.process.stdout), new node_1.StreamMessageWriter(this.process.stdin));
145
+ this.connection.listen();
146
+ // Forward server log/diagnostics to stderr when CWTOOLS_MCP_DEBUG is set, so
147
+ // load/cache problems are visible (the server logs via window/logMessage).
148
+ if (process.env.CWTOOLS_MCP_DEBUG) {
149
+ this.connection.onNotification('window/logMessage', (p = {}) => {
150
+ if (p.message)
151
+ process.stderr.write(`[cwtools-lsp] ${String(p.message).slice(0, 240)}\n`);
152
+ });
153
+ }
154
+ const rootUri = pathToFileUri(this.options.workspaceRoot);
155
+ const game = this.options.game ?? 'stellaris';
156
+ const rulesCacheRoot = resolveRulesCacheRoot(this.options);
157
+ const rulesFolder = this.options.bundledRulesPath ?? resolveBundledRulesPath(game, rulesCacheRoot);
158
+ if (!rulesFolder) {
159
+ process.stderr.write('[cwtools-mcp] warning: no CWT rules found — install the VS Code extension or pass --rules <dir>; validation will be limited\n');
160
+ }
161
+ await this.connection.sendRequest('initialize', {
162
+ processId: process.pid,
163
+ rootPath: this.options.workspaceRoot,
164
+ rootUri,
165
+ workspaceFolders: [
166
+ {
167
+ uri: rootUri,
168
+ name: path.basename(this.options.workspaceRoot),
169
+ },
170
+ ],
171
+ capabilities: {
172
+ workspace: {
173
+ configuration: true,
174
+ workspaceFolders: true,
175
+ },
176
+ textDocument: {
177
+ synchronization: {
178
+ didSave: true,
179
+ },
180
+ },
181
+ },
182
+ initializationOptions: {
183
+ language: game,
184
+ uiLanguage: 'en',
185
+ isVanillaFolder: false,
186
+ rulesCache: rulesCacheRoot,
187
+ bundledRulesPath: rulesFolder ?? '',
188
+ rules_version: 'manual',
189
+ defaultRepoPath: '',
190
+ repoPath: '',
191
+ diagnosticLogging: false,
192
+ },
193
+ trace: 'off',
194
+ });
195
+ void this.connection.sendNotification('initialized', {});
196
+ const loc = (0, projectSettings_1.resolveLocalisationLanguages)(this.options.workspaceRoot);
197
+ process.stderr.write(`[cwtools-mcp] info: localisation languages = [${loc.languages.join(', ')}] (${loc.source})\n`);
198
+ void this.connection.sendNotification('workspace/didChangeConfiguration', {
199
+ settings: {
200
+ cwtools: buildCwtoolsConfiguration(game, this.options.gamePath, rulesFolder ?? '', {
201
+ languages: loc.languages,
202
+ generatedStrings: (0, projectSettings_1.resolveGeneratedStrings)(this.options.workspaceRoot),
203
+ }, (0, projectSettings_1.resolveExperimental)(this.options.workspaceRoot)),
204
+ },
205
+ });
206
+ await this.waitForExecuteCommandsReady(20000);
207
+ this.startFileWatcher();
208
+ this.startedAtMs = Date.now();
209
+ }
210
+ startFileWatcher() {
211
+ if (this.fileWatcher)
212
+ return;
213
+ const watcher = (0, chokidar_1.watch)(this.options.workspaceRoot, {
214
+ ignoreInitial: true,
215
+ ignored: [/(^|[\\/])(?:node_modules|\.git|\.cwtools|\.cwtools-ai)(?:[\\/]|$)/],
216
+ awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 30 },
217
+ });
218
+ watcher.on('add', filePath => this.queueWatchedFileChange(filePath, 1));
219
+ watcher.on('change', filePath => this.queueWatchedFileChange(filePath, 2));
220
+ watcher.on('unlink', filePath => this.queueWatchedFileChange(filePath, 3));
221
+ watcher.on('error', error => {
222
+ if (process.env.CWTOOLS_MCP_DEBUG) {
223
+ process.stderr.write(`[cwtools-mcp] file watcher error: ${String(error)}\n`);
224
+ }
225
+ });
226
+ this.fileWatcher = watcher;
227
+ }
228
+ stopFileWatcher() {
229
+ if (this.watcherFlushTimer)
230
+ clearTimeout(this.watcherFlushTimer);
231
+ this.watcherFlushTimer = undefined;
232
+ this.pendingWatchedChanges.clear();
233
+ const watcher = this.fileWatcher;
234
+ this.fileWatcher = undefined;
235
+ void watcher?.close();
236
+ }
237
+ queueWatchedFileChange(filePath, type) {
238
+ if (!isLspWatchedFile(this.options.workspaceRoot, filePath, this.options.game))
239
+ return;
240
+ const resolved = path.resolve(filePath);
241
+ const previous = this.pendingWatchedChanges.get(resolved);
242
+ // Preserve a create over its following content-change event; deletion always wins.
243
+ const nextType = type === 3 ? 3 : previous === 1 ? 1 : type;
244
+ this.pendingWatchedChanges.set(resolved, nextType);
245
+ if (this.watcherFlushTimer)
246
+ clearTimeout(this.watcherFlushTimer);
247
+ this.watcherFlushTimer = setTimeout(() => this.flushWatchedFileChanges(), 100);
248
+ }
249
+ flushWatchedFileChanges() {
250
+ this.watcherFlushTimer = undefined;
251
+ const connection = this.connection;
252
+ if (!connection || this.pendingWatchedChanges.size === 0)
253
+ return;
254
+ const changes = Array.from(this.pendingWatchedChanges, ([filePath, type]) => ({ filePath, type }))
255
+ .sort((left, right) => left.filePath.localeCompare(right.filePath))
256
+ .map(({ filePath, type }) => ({
257
+ uri: pathToFileUri(filePath),
258
+ type,
259
+ }));
260
+ this.pendingWatchedChanges.clear();
261
+ void connection.sendNotification('workspace/didChangeWatchedFiles', { changes });
262
+ }
263
+ unavailable(message) {
264
+ return {
265
+ ok: false,
266
+ status: 'unavailable',
267
+ error: {
268
+ code: 'lsp_unavailable',
269
+ message,
270
+ },
271
+ };
272
+ }
273
+ async withTimeout(promise, timeoutMs, message) {
274
+ let timer;
275
+ try {
276
+ return await Promise.race([
277
+ promise,
278
+ new Promise((_, reject) => {
279
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
280
+ }),
281
+ ]);
282
+ }
283
+ finally {
284
+ if (timer)
285
+ clearTimeout(timer);
286
+ }
287
+ }
288
+ async waitForExecuteCommandsReady(timeoutMs) {
289
+ const start = Date.now();
290
+ while (Date.now() - start < timeoutMs) {
291
+ const result = await this.connection.sendRequest('workspace/executeCommand', {
292
+ command: 'cwtools.ai.getValidationStatus',
293
+ arguments: [],
294
+ }).catch(() => null);
295
+ if (result && typeof result === 'object')
296
+ return;
297
+ await new Promise(resolve => setTimeout(resolve, 500));
298
+ }
299
+ }
300
+ }
301
+ exports.LspProcessHost = LspProcessHost;
302
+ function buildCwtoolsConfiguration(game, gamePath, rulesFolder, localisation, experimental) {
303
+ // `cache.<game>` is the vanilla install/data dir (the server reads vanilla data
304
+ // from here and serializes the .cwb cache). Empty string => no vanilla data.
305
+ const vanillaDir = gamePath ?? '';
306
+ return {
307
+ localisation: {
308
+ languages: localisation.languages,
309
+ generated_strings: localisation.generatedStrings,
310
+ },
311
+ errors: {
312
+ vanilla: false,
313
+ ignore: [],
314
+ ignorefiles: [],
315
+ },
316
+ // On by default: enables incremental scripted-type refresh so revalidating a
317
+ // scripted_trigger/effect/value patches the type index fast instead of a full reload.
318
+ experimental,
319
+ debug_mode: false,
320
+ ignore_patterns: [],
321
+ trace: {
322
+ server: 'off',
323
+ },
324
+ cache: {
325
+ stellaris: '',
326
+ hoi4: '',
327
+ eu4: '',
328
+ ck2: '',
329
+ imperator: '',
330
+ vic2: '',
331
+ ck3: '',
332
+ vic3: '',
333
+ eu5: '',
334
+ [game]: vanillaDir,
335
+ },
336
+ rules_folder: rulesFolder,
337
+ showInlineText: false,
338
+ maxFileSize: 2,
339
+ diagnostics: {
340
+ deferDynamicParameterDiagnostics: true,
341
+ dynamicPreflightTimeoutMs: 250,
342
+ dynamicPreflightMaxEntities: 300,
343
+ dynamicDeferDelayMs: 800,
344
+ },
345
+ };
346
+ }
347
+ function createLspProcessHost(options) {
348
+ return new LspProcessHost(options);
349
+ }
350
+ function resolveDefaultServerPath() {
351
+ const platform = os.platform();
352
+ const executable = platform === 'win32'
353
+ ? path.join('win-x64', 'CWTools Server.exe')
354
+ : platform === 'darwin'
355
+ ? path.join('osx-x64', 'CWTools Server')
356
+ : path.join('linux-x64', 'CWTools Server');
357
+ const repoRoot = path.resolve(__dirname, '..', '..', '..', '..');
358
+ // Prefer the server inside the installed VS Code extension so a user with no dev
359
+ // checkout still gets a working server; fall back to dev-build locations.
360
+ const installed = (0, vscodeCache_1.detectExtensionServerPath)();
361
+ const candidates = [
362
+ ...(installed ? [installed] : []),
363
+ path.join(process.cwd(), 'release', 'bin', 'server', executable),
364
+ path.join(process.cwd(), 'bin', 'server', executable),
365
+ path.join(repoRoot, 'release', 'bin', 'server', executable),
366
+ path.join(repoRoot, 'bin', 'server', executable),
367
+ path.join(repoRoot, 'src', 'Main', 'output', platform === 'win32' ? 'CWTools Server.exe' : 'CWTools Server'),
368
+ ];
369
+ return candidates.find(candidate => fs.existsSync(candidate)) ?? candidates[0];
370
+ }
371
+ // Resolve a rules *directory* the server can load. Priority: the rules the
372
+ // installed extension pulled into globalStorage, then a dev checkout. No bundled
373
+ // .zip and no extraction — the only zip-free sources. Returns undefined when none
374
+ // is found (the caller warns; --rules is the explicit override).
375
+ function resolveBundledRulesPath(game, cacheDir) {
376
+ const extracted = (0, vscodeCache_1.detectExtensionRulesDir)(cacheDir, game);
377
+ if (extracted)
378
+ return extracted;
379
+ const repoRoot = path.resolve(__dirname, '..', '..', '..', '..');
380
+ const candidates = [
381
+ path.join(process.cwd(), 'release', 'rules', game, 'config'),
382
+ path.join(process.cwd(), 'submodules', `cwtools-${game}-config`, 'config'),
383
+ game === 'stellaris' ? path.join(process.cwd(), 'submodules', 'cwtools-stellaris-config', 'config') : '',
384
+ path.join(repoRoot, 'release', 'rules', game, 'config'),
385
+ path.join(repoRoot, 'submodules', `cwtools-${game}-config`, 'config'),
386
+ game === 'stellaris' ? path.join(repoRoot, 'submodules', 'cwtools-stellaris-config', 'config') : '',
387
+ ].filter(Boolean);
388
+ return candidates.find(candidate => fs.existsSync(candidate));
389
+ }
390
+ function pathToFileUri(filePath) {
391
+ const resolved = path.resolve(filePath).replace(/\\/g, '/');
392
+ const withLeadingSlash = resolved.startsWith('/') ? resolved : `/${resolved}`;
393
+ return `file://${encodeURI(withLeadingSlash).replace(/#/g, '%23')}`;
394
+ }
395
+ const LSP_WATCHED_EXTENSIONS = new Set([
396
+ '.txt', '.gui', '.yml', '.csv', '.gfx', '.asset', '.cwt', '.entity', '.shader', '.fxh',
397
+ ]);
398
+ function isLspWatchedFile(workspaceRoot, filePath, game) {
399
+ const relative = path.relative(path.resolve(workspaceRoot), path.resolve(filePath));
400
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
401
+ return false;
402
+ const segments = relative.split(/[\\/]+/).map(segment => segment.toLowerCase());
403
+ if (segments.some(segment => segment === 'node_modules'
404
+ || segment === '.git'
405
+ || segment === '.cwtools'
406
+ || segment === '.cwtools-ai'))
407
+ return false;
408
+ const extension = path.extname(filePath).toLowerCase();
409
+ if (extension === '.csv' && game && game.toLowerCase() !== 'ck2')
410
+ return false;
411
+ return LSP_WATCHED_EXTENSIONS.has(extension);
412
+ }
@@ -0,0 +1,3 @@
1
+ import { type HostServices } from 'cwtools-shared';
2
+ import type { CwtoolsMcpConfig } from '../config';
3
+ export declare function createNodeHostServices(config: CwtoolsMcpConfig): HostServices;