purescript-mcp-tools 1.0.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.
package/index.js ADDED
@@ -0,0 +1,1287 @@
1
+ const { Parser, Language, Query } = require('web-tree-sitter');
2
+ const path = require('path');
3
+ const { spawn } = require('child_process');
4
+ const net = require('net');
5
+ const chalk = require('chalk');
6
+ const fs = require('fs').promises;
7
+ const readline = require('readline');
8
+
9
+ // --- Log File ---
10
+ const LOG_FILE_PATH = path.join(__dirname, 'purescript-mcp-server.log');
11
+
12
+ // --- Tree-sitter PureScript Parser State ---
13
+ let PureScriptLanguage;
14
+ let purescriptTsParser;
15
+ let treeSitterInitialized = false;
16
+
17
+ // --- purs ide Server State ---
18
+ let pursIdeProcess = null;
19
+ let pursIdeServerPort = null;
20
+ let pursIdeProjectPath = null;
21
+ let pursIdeIsReady = false;
22
+ let pursIdeLogBuffer = [];
23
+ const MAX_IDE_LOG_BUFFER = 200;
24
+
25
+ // --- Constants for Dependency Graph ---
26
+ const MAX_RESULTS_COMPLETIONS_FOR_GRAPH = 10000;
27
+ const ENCLOSING_DECL_QUERY_SOURCE = `(function name: (_) @name) @decl ;; Covers top-level bindings and functions`;
28
+ const MODULE_NAME_QUERY_SOURCE = "(purescript name: (qualified_module) @qmodule.name_node)";
29
+ let ENCLOSING_DECL_TS_QUERY;
30
+ let MODULE_NAME_TS_QUERY;
31
+
32
+ // --- Server Info and Capabilities (MCP Standard) ---
33
+ const SERVER_INFO = {
34
+ name: 'purescript-tools-mcp',
35
+ version: '1.1.0', // Updated version for stdio protocol change
36
+ description: 'Provides tools for PureScript development tasks via stdio MCP.'
37
+ };
38
+
39
+ // Fix: Update SERVER_CAPABILITIES declaration
40
+ const SERVER_CAPABILITIES = {
41
+ resources: {}, // Empty if no resources
42
+ tools: {}, // Tools are listed by tools/list, not in capabilities directly for full definitions
43
+ resourceTemplates: {} // Empty if no resource templates
44
+ };
45
+
46
+ // --- Logging ---
47
+ function logToStderr(message, level = 'info') {
48
+ const timestamp = new Date().toISOString();
49
+ const plainMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
50
+
51
+ // Only write to log file to avoid stdio conflicts
52
+ fs.appendFile(LOG_FILE_PATH, plainMessage + '\n')
53
+ .catch(err => {
54
+ // Silently fail if file logging fails to avoid recursive stderr issues
55
+ // Alternative: write to a different error log file if needed
56
+ });
57
+ }
58
+
59
+ function logPursIdeOutput(data, type = 'stdout') {
60
+ const message = data.toString().trim();
61
+ const logType = type === 'stderr' ? 'error' : 'info';
62
+ logToStderr(`[purs ide ${type}]: ${message}`, logType);
63
+ pursIdeLogBuffer.push(`[${type}] ${message}`);
64
+ if (pursIdeLogBuffer.length > MAX_IDE_LOG_BUFFER) pursIdeLogBuffer.shift();
65
+ }
66
+
67
+ // --- Initialization ---
68
+ async function initializeTreeSitterParser() {
69
+ try {
70
+ await Parser.init();
71
+ const wasmPath = path.join(__dirname, 'tree-sitter-purescript.wasm');
72
+ PureScriptLanguage = await Language.load(wasmPath);
73
+ purescriptTsParser = new Parser();
74
+ purescriptTsParser.setLanguage(PureScriptLanguage);
75
+ ENCLOSING_DECL_TS_QUERY = new Query(PureScriptLanguage, ENCLOSING_DECL_QUERY_SOURCE);
76
+ MODULE_NAME_TS_QUERY = new Query(PureScriptLanguage, MODULE_NAME_QUERY_SOURCE);
77
+ treeSitterInitialized = true;
78
+ logToStderr("Tree-sitter PureScript grammar and parser initialized successfully.", "info");
79
+ } catch (error) {
80
+ logToStderr(`Failed to load Tree-sitter PureScript grammar: ${error.message}`, "error");
81
+ }
82
+ }
83
+
84
+ // --- Helper Functions ---
85
+ function getNamespaceForDeclaration(declarationType) { /* ... */ }
86
+ function getDeclarationId(decl) { /* ... */ }
87
+ function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
88
+
89
+ // Helper to find an available random port
90
+ function findAvailablePort(startPort = 4242, endPort = 65535) {
91
+ return new Promise((resolve, reject) => {
92
+ const randomPort = Math.floor(Math.random() * (endPort - startPort + 1)) + startPort;
93
+ const server = net.createServer();
94
+
95
+ server.listen(randomPort, (err) => {
96
+ if (err) {
97
+ // Port is busy, try another random port
98
+ server.close();
99
+ if (randomPort === endPort) {
100
+ reject(new Error(`No available ports found in range ${startPort}-${endPort}`));
101
+ } else {
102
+ // Try a different random port
103
+ resolve(findAvailablePort(startPort, endPort));
104
+ }
105
+ } else {
106
+ const port = server.address().port;
107
+ server.close(() => {
108
+ resolve(port);
109
+ });
110
+ }
111
+ });
112
+
113
+ server.on('error', (err) => {
114
+ if (err.code === 'EADDRINUSE') {
115
+ server.close();
116
+ // Try a different random port
117
+ resolve(findAvailablePort(startPort, endPort));
118
+ } else {
119
+ reject(err);
120
+ }
121
+ });
122
+ });
123
+ }
124
+
125
+ // Helper to get code from input args (filePath or code string)
126
+ async function getCodeFromInput(args, isModuleOriented = true) {
127
+ if (isModuleOriented) {
128
+ const hasFilePath = args && typeof args.filePath === 'string';
129
+ const hasCode = args && typeof args.code === 'string';
130
+
131
+ if ((hasFilePath && hasCode) || (!hasFilePath && !hasCode)) {
132
+ throw new Error("Invalid input: Exactly one of 'filePath' or 'code' must be provided for module-oriented tools.");
133
+ }
134
+ if (hasFilePath) {
135
+ if (!path.isAbsolute(args.filePath)) {
136
+ throw new Error(`Invalid filePath: '${args.filePath}' is not an absolute path. Only absolute paths are supported.`);
137
+ }
138
+ try {
139
+ return await fs.readFile(args.filePath, 'utf-8');
140
+ } catch (e) {
141
+ throw new Error(`Failed to read file at ${args.filePath}: ${e.message}`);
142
+ }
143
+ }
144
+ return args.code;
145
+ } else { // Snippet-oriented
146
+ if (!args || typeof args.code !== 'string') {
147
+ throw new Error("Invalid input: 'code' (string) is required for snippet-oriented tools.");
148
+ }
149
+ return args.code;
150
+ }
151
+ }
152
+
153
+ // (Full implementations for getNamespaceForDeclaration and getDeclarationId are kept from previous version)
154
+ getNamespaceForDeclaration = function(declarationType) {
155
+ switch (declarationType) {
156
+ case "value": case "valueoperator": case "dataconstructor": return "value";
157
+ case "type": case "typeoperator": case "synonym": case "typeclass": return "type";
158
+ case "kind": return "kind";
159
+ default: return null;
160
+ }
161
+ };
162
+ getDeclarationId = function(decl) {
163
+ if (!decl || !decl.module || !decl.identifier) return `unknown.${Date.now()}.${Math.random()}`;
164
+ return `${decl.module}.${decl.identifier}`;
165
+ };
166
+
167
+
168
+ // --- `purs ide` Communication & Management (largely unchanged) ---
169
+ function sendCommandToPursIde(commandPayload) {
170
+ return new Promise((resolve, reject) => {
171
+ if (!pursIdeProcess || !pursIdeIsReady || !pursIdeServerPort) {
172
+ return reject(new Error("purs ide server is not running or not ready."));
173
+ }
174
+ const client = new net.Socket();
175
+ let responseData = '';
176
+ client.connect(pursIdeServerPort, '127.0.0.1', () => {
177
+ logToStderr(`[MCP Client->purs ide]: Sending command: ${JSON.stringify(commandPayload).substring(0,100)}...`, 'debug');
178
+ client.write(JSON.stringify(commandPayload) + '\n');
179
+ });
180
+ client.on('data', (data) => {
181
+ responseData += data.toString();
182
+ if (responseData.includes('\n')) {
183
+ const completeResponses = responseData.split('\n').filter(Boolean);
184
+ responseData = '';
185
+ if (completeResponses.length > 0) {
186
+ try {
187
+ resolve(JSON.parse(completeResponses[0].trim()));
188
+ } catch (e) {
189
+ reject(new Error(`Failed to parse JSON response from purs ide: ${e.message}. Raw: ${completeResponses[0]}`));
190
+ }
191
+ }
192
+ client.end();
193
+ }
194
+ });
195
+ client.on('end', () => {
196
+ if (responseData.trim()) {
197
+ try { resolve(JSON.parse(responseData.trim())); }
198
+ catch (e) { reject(new Error(`Failed to parse JSON response from purs ide on end: ${e.message}. Raw: ${responseData}`));}
199
+ }
200
+ });
201
+ client.on('close', () => { logToStderr(`[MCP Client->purs ide]: Connection closed.`, 'debug'); });
202
+ client.on('error', (err) => reject(new Error(`TCP connection error with purs ide server: ${err.message}`)));
203
+ });
204
+ }
205
+
206
+ // --- Internal Tool Handlers (adapted from previous version) ---
207
+ // These functions now expect 'args' to be the 'params' or 'arguments' object from the 'tools/call' request.
208
+ // Updated to return MCP standard response format { content: [{type: "text", text: ...}] }
209
+
210
+ async function internalHandleGetServerStatus() {
211
+ const statusResponse = {
212
+ status: 'running', // Overall server status
213
+ purescript_tools_mcp_version: SERVER_INFO.version,
214
+ treeSitterInitialized,
215
+ purs_ide_server_status: { // Renamed for clarity and to match test assertion expectation
216
+ status: pursIdeProcess ? (pursIdeIsReady ? 'ready' : 'starting') : (pursIdeServerPort ? 'stopped' : 'not_started'),
217
+ running: !!pursIdeProcess, // Keep this for direct boolean check if needed
218
+ ready: pursIdeIsReady,
219
+ port: pursIdeServerPort,
220
+ projectPath: pursIdeProjectPath,
221
+ recentLogs: pursIdeLogBuffer.slice(-10)
222
+ }
223
+ };
224
+ return { content: [{ type: "text", text: JSON.stringify(statusResponse, null, 2) }] };
225
+ }
226
+
227
+ async function internalHandleEcho(args) {
228
+ if (!args || typeof args.message !== 'string') {
229
+ throw new Error("Invalid input. 'message' (string) is required.");
230
+ }
231
+ return { content: [{ type: "text", text: `Echo: ${args.message}` }] };
232
+ }
233
+
234
+ async function internalHandleQueryPurescriptAst(args) {
235
+ if (!treeSitterInitialized || !PureScriptLanguage || !purescriptTsParser) {
236
+ throw new Error("Tree-sitter PureScript grammar/parser not loaded.");
237
+ }
238
+ if (!args || typeof args.purescript_code !== 'string' || typeof args.tree_sitter_query !== 'string') {
239
+ throw new Error("Invalid input: 'purescript_code' and 'tree_sitter_query' (strings) are required.");
240
+ }
241
+ const tree = purescriptTsParser.parse(args.purescript_code);
242
+ const query = new Query(PureScriptLanguage, args.tree_sitter_query);
243
+ const captures = query.captures(tree.rootNode);
244
+ const results = captures.map(capture => ({
245
+ name: capture.name,
246
+ text: capture.node.text
247
+ }));
248
+ return { content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }] };
249
+ }
250
+
251
+ async function internalHandleStartPursIdeServer(args) {
252
+ if (pursIdeProcess) {
253
+ logToStderr("Stopping existing purs ide server before starting a new one.", "warn");
254
+ pursIdeProcess.kill();
255
+ pursIdeProcess = null;
256
+ pursIdeIsReady = false;
257
+ }
258
+
259
+ if (!args.project_path || typeof args.project_path !== 'string') {
260
+ throw new Error("Invalid input: 'project_path' (string) is required for start_purs_ide_server.");
261
+ }
262
+
263
+ // Always use a random available port
264
+ try {
265
+ pursIdeServerPort = await findAvailablePort();
266
+ logToStderr(`Using random available port: ${pursIdeServerPort}`, "info");
267
+ } catch (portError) {
268
+ throw new Error(`Failed to find available port: ${portError.message}`);
269
+ }
270
+
271
+ if (!path.isAbsolute(args.project_path)) {
272
+ throw new Error(`Invalid project_path: '${args.project_path}' is not an absolute path. Only absolute paths are supported.`);
273
+ }
274
+
275
+ const resolvedProjectPath = args.project_path;
276
+
277
+ // Validate that the resolved path exists and is a directory
278
+ try {
279
+ const stats = await fs.stat(resolvedProjectPath);
280
+ if (!stats.isDirectory()) {
281
+ throw new Error(`Project path '${resolvedProjectPath}' is not a directory.`);
282
+ }
283
+ logToStderr(`Validated project directory exists: "${resolvedProjectPath}"`, 'debug');
284
+ } catch (e) {
285
+ throw new Error(`Invalid project_path: ${resolvedProjectPath}. Error: ${e.message}`);
286
+ }
287
+
288
+ pursIdeProjectPath = resolvedProjectPath;
289
+ const outputDir = args.output_directory || "output/";
290
+ const sourceGlobs = args.source_globs || ["src/**/*.purs", ".spago/*/*/src/**/*.purs", "test/**/*.purs"];
291
+ const logLevel = args.log_level || "none";
292
+ pursIdeLogBuffer = [];
293
+
294
+ const cmdArgs = ['ide', 'server', '--port', pursIdeServerPort.toString(), '--output-directory', outputDir, '--log-level', logLevel, ...sourceGlobs];
295
+ const fullCommand = `npx purs ${cmdArgs.join(' ')}`;
296
+ logToStderr(`Spawning '${fullCommand}' in CWD: ${pursIdeProjectPath}`, "info");
297
+
298
+ return new Promise((resolve, reject) => {
299
+ pursIdeProcess = spawn('npx', ['purs', ...cmdArgs], { cwd: pursIdeProjectPath, shell: false, env: process.env });
300
+ pursIdeIsReady = false;
301
+
302
+ pursIdeProcess.stdout.on('data', (data) => logPursIdeOutput(data, 'stdout'));
303
+ pursIdeProcess.stderr.on('data', (data) => logPursIdeOutput(data, 'stderr'));
304
+
305
+ pursIdeProcess.on('error', (err) => {
306
+ const errorMsg = `Failed to start purs ide server process: ${err.message}`;
307
+ logPursIdeOutput(errorMsg, 'error');
308
+ pursIdeProcess = null;
309
+ reject(new Error(errorMsg));
310
+ });
311
+
312
+ pursIdeProcess.on('close', (code) => {
313
+ const codeMessage = `purs ide server process exited with code ${code}`;
314
+ logPursIdeOutput(codeMessage, code === 0 ? 'info' : 'error');
315
+
316
+ if (pursIdeProcess) {
317
+ pursIdeProcess = null;
318
+ pursIdeIsReady = false;
319
+ }
320
+
321
+ if (code !== 0) {
322
+ reject(new Error(`Server failed to start (exit code ${code})`));
323
+ }
324
+ });
325
+
326
+ // Check if server started successfully after a short delay
327
+ setTimeout(async () => {
328
+ if (!pursIdeProcess) {
329
+ return; // Process already exited
330
+ }
331
+
332
+ try {
333
+ logToStderr("Attempting initial 'load' command to purs ide server...", "info");
334
+ pursIdeIsReady = true;
335
+ const loadResult = await sendCommandToPursIde({ command: "load", params: {} });
336
+
337
+ // Check if the load command returned an error
338
+ if (loadResult.resultType === "error") {
339
+ pursIdeIsReady = false;
340
+ logToStderr(`Initial 'load' command failed: ${loadResult.result}`, "error");
341
+ if(pursIdeProcess) {
342
+ pursIdeProcess.kill();
343
+ pursIdeProcess = null;
344
+ }
345
+
346
+ // Provide helpful error message based on common issues
347
+ let errorMessage = `Failed to load PureScript project: ${loadResult.result}`;
348
+ if (typeof loadResult.result === 'string' && loadResult.result.includes("output directory")) {
349
+ errorMessage += "\n\nThe project needs to be built first. Run 'spago build' in the project directory before starting the IDE server.";
350
+ }
351
+
352
+ reject(new Error(errorMessage));
353
+ return;
354
+ }
355
+
356
+ logToStderr("Initial 'load' command to purs ide server successful.", "info");
357
+ const result = {
358
+ status_message: "purs ide server started and initial load attempted.",
359
+ command_executed: fullCommand,
360
+ port: pursIdeServerPort,
361
+ project_path: pursIdeProjectPath,
362
+ initial_load_result: loadResult,
363
+ logs: pursIdeLogBuffer.slice(-20)
364
+ };
365
+ resolve({ content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
366
+ } catch (error) {
367
+ pursIdeIsReady = false;
368
+ logToStderr(`Error during initial 'load' to purs ide server: ${error.message}`, "error");
369
+ if(pursIdeProcess) {
370
+ pursIdeProcess.kill();
371
+ pursIdeProcess = null;
372
+ }
373
+ reject(new Error(`purs ide server started but initial load command failed: ${error.message}`));
374
+ }
375
+ }, 3000);
376
+ });
377
+ }
378
+
379
+ async function internalHandleStopPursIdeServer() {
380
+ let message;
381
+ if (pursIdeProcess) {
382
+ pursIdeProcess.kill();
383
+ pursIdeProcess = null;
384
+ pursIdeIsReady = false;
385
+ logPursIdeOutput("purs ide server stopped by MCP.", "info");
386
+ message = "purs ide server stopped.";
387
+ } else {
388
+ message = "No purs ide server was running.";
389
+ }
390
+ return { content: [{ type: "text", text: JSON.stringify({ status_message: message }, null, 2) }] };
391
+ }
392
+
393
+ async function internalHandleQueryPursIde(args) {
394
+ if (!pursIdeProcess || !pursIdeIsReady) {
395
+ throw new Error("purs ide server is not running or not ready. Please start it first.");
396
+ }
397
+ if (!args || typeof args.purs_ide_command !== 'string') {
398
+ throw new Error("Invalid input. 'purs_ide_command' (string) is required.");
399
+ }
400
+ const result = await sendCommandToPursIde({
401
+ command: args.purs_ide_command,
402
+ params: args.purs_ide_params || {}
403
+ });
404
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
405
+ }
406
+
407
+ async function internalHandleGenerateDependencyGraph(args) {
408
+ if (!pursIdeProcess || !pursIdeIsReady) throw new Error("purs ide server is not running or not ready. Please start it first.");
409
+ if (!treeSitterInitialized || !purescriptTsParser || !PureScriptLanguage) throw new Error("Tree-sitter parser for PureScript not initialized.");
410
+ if (!args || !Array.isArray(args.target_modules) || args.target_modules.some(m => typeof m !== 'string')) {
411
+ throw new Error("Invalid input: 'target_modules' (array of strings) is required.");
412
+ }
413
+
414
+ const { target_modules, max_concurrent_requests = 5 } = args;
415
+ const graphNodesMap = new Map();
416
+ const graphNodesList = [];
417
+ logToStderr(`[DepGraph]: Phase 1: Identifying all declarations in [${target_modules.join(', ')}]...`, "info");
418
+
419
+ for (const moduleName of target_modules) {
420
+ try {
421
+ const completeResponse = await sendCommandToPursIde({
422
+ command: "complete",
423
+ params: { filters: [{ filter: "modules", params: { modules: [moduleName] } }], matcher: {}, options: { maxResults: MAX_RESULTS_COMPLETIONS_FOR_GRAPH, groupReexports: false } }
424
+ });
425
+ if (completeResponse.resultType === "success" && Array.isArray(completeResponse.result)) {
426
+ completeResponse.result.forEach(decl => {
427
+ if (decl.definedAt && decl.definedAt.name) {
428
+ const declId = getDeclarationId(decl);
429
+ if (!graphNodesMap.has(declId)) {
430
+ const node = {
431
+ id: declId, module: decl.module, identifier: decl.identifier, type: decl.type,
432
+ declarationType: decl.declarationType, definedAt: decl.definedAt,
433
+ filePath: path.relative(pursIdeProjectPath || process.cwd(), decl.definedAt.name), usedBy: []
434
+ };
435
+ graphNodesMap.set(declId, node);
436
+ graphNodesList.push(node);
437
+ }
438
+ }
439
+ });
440
+ } else {
441
+ logToStderr(`[DepGraph]: Could not get completions for module ${moduleName}: ${JSON.stringify(completeResponse.result)}`, "warn");
442
+ }
443
+ } catch (error) {
444
+ logToStderr(`[DepGraph]: Error fetching completions for module ${moduleName}: ${error.message}`, "error");
445
+ }
446
+ }
447
+ logToStderr(`[DepGraph]: Identified ${graphNodesList.length} declarations with source locations.`, "info");
448
+ logToStderr(`[DepGraph]: Phase 2: Identifying dependencies...`, "info");
449
+
450
+ const processUsageQueue = [];
451
+ let activePromises = 0;
452
+ let processedDeclarations = 0;
453
+
454
+ for (const sourceDeclNode of graphNodesList) {
455
+ const taskFn = async () => {
456
+ activePromises++;
457
+ try {
458
+ const namespace = getNamespaceForDeclaration(sourceDeclNode.declarationType);
459
+ if (!namespace) return;
460
+
461
+ const usagesResponse = await sendCommandToPursIde({
462
+ command: "usages", params: { module: sourceDeclNode.module, identifier: sourceDeclNode.identifier, namespace: namespace }
463
+ });
464
+
465
+ if (usagesResponse.resultType === "success" && Array.isArray(usagesResponse.result)) {
466
+ const usagesByFile = {};
467
+ usagesResponse.result.forEach(usageLoc => {
468
+ if (usageLoc && usageLoc.name && usageLoc.start && usageLoc.end) {
469
+ if (!usagesByFile[usageLoc.name]) usagesByFile[usageLoc.name] = [];
470
+ usagesByFile[usageLoc.name].push(usageLoc);
471
+ }
472
+ });
473
+
474
+ for (const absoluteFilePath in usagesByFile) {
475
+ try {
476
+ const resolvedFilePath = path.isAbsolute(absoluteFilePath) ? absoluteFilePath : path.resolve(pursIdeProjectPath, absoluteFilePath);
477
+ const fileContent = await fs.readFile(resolvedFilePath, "utf-8");
478
+ const tree = purescriptTsParser.parse(fileContent);
479
+ const relativeFilePath = path.relative(pursIdeProjectPath || process.cwd(), resolvedFilePath);
480
+
481
+ usagesByFile[absoluteFilePath].forEach(usageLocation => {
482
+ const usageStartPoint = { row: usageLocation.start[0] - 1, column: usageLocation.start[1] - 1 };
483
+ const usageEndPoint = { row: usageLocation.end[0] - 1, column: usageLocation.end[1] - 1 };
484
+
485
+ let bestMatchDeclNode = null;
486
+ let bestMatchNameNodeText = null;
487
+ let smallestSpanSize = Infinity;
488
+
489
+ const matches = ENCLOSING_DECL_TS_QUERY.matches(tree.rootNode);
490
+ for (const match of matches) {
491
+ const declCapture = match.captures.find(c => c.name === 'decl');
492
+ const nameCapture = match.captures.find(c => c.name === 'name');
493
+
494
+ if (declCapture && nameCapture) {
495
+ const declNode = declCapture.node;
496
+ const declStartPoint = declNode.startPosition;
497
+ const declEndPoint = declNode.endPosition;
498
+
499
+ let withinBounds = usageStartPoint.row >= declStartPoint.row && usageEndPoint.row <= declEndPoint.row;
500
+ if (withinBounds && usageStartPoint.row === declStartPoint.row && usageStartPoint.column < declStartPoint.column) withinBounds = false;
501
+ if (withinBounds && usageEndPoint.row === declEndPoint.row && usageEndPoint.column > declEndPoint.column) withinBounds = false;
502
+
503
+ if (withinBounds) {
504
+ const spanSize = (declEndPoint.row - declStartPoint.row) * 10000 + (declEndPoint.column - (declStartPoint.row === declEndPoint.row ? declStartPoint.column : 0));
505
+ if (spanSize < smallestSpanSize) {
506
+ smallestSpanSize = spanSize;
507
+ bestMatchDeclNode = declNode;
508
+ bestMatchNameNodeText = nameCapture.node.text;
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ if (bestMatchDeclNode && bestMatchNameNodeText) {
515
+ const callerIdentifierName = bestMatchNameNodeText;
516
+ let callerModuleName = null;
517
+ const moduleNameMatches = MODULE_NAME_TS_QUERY.matches(tree.rootNode);
518
+ if (moduleNameMatches.length > 0 && moduleNameMatches[0].captures.length > 0) {
519
+ const qmNodeCap = moduleNameMatches[0].captures.find(c => c.name === "qmodule.name_node");
520
+ if(qmNodeCap) callerModuleName = qmNodeCap.node.text.replace(/\\s+/g, "");
521
+ }
522
+
523
+ if (callerIdentifierName && callerModuleName) {
524
+ const callerId = `${callerModuleName}.${callerIdentifierName}`;
525
+ const usageDetail = { file: relativeFilePath, moduleName: callerModuleName, declarationName: callerIdentifierName, startLine: usageLocation.start[0], startCol: usageLocation.start[1], endLine: usageLocation.end[0], endCol: usageLocation.end[1] };
526
+
527
+ const targetNode = graphNodesMap.get(sourceDeclNode.id);
528
+ if(targetNode){
529
+ let existingCaller = targetNode.usedBy.find(u => u.from === callerId);
530
+ if (!existingCaller) {
531
+ existingCaller = { from: callerId, usagesAt: [] };
532
+ targetNode.usedBy.push(existingCaller);
533
+ }
534
+ if (!existingCaller.usagesAt.some(ud => ud.file === usageDetail.file && ud.moduleName === usageDetail.moduleName && ud.declarationName === usageDetail.declarationName && ud.startLine === usageDetail.startLine && ud.startCol === usageDetail.startCol && ud.endLine === usageDetail.endLine && ud.endCol === usageDetail.endCol)) {
535
+ existingCaller.usagesAt.push(usageDetail);
536
+ }
537
+ }
538
+ } else {
539
+ logToStderr(`[DepGraph]: Could not extract caller module/id for usage in ${relativeFilePath} at L${usageLocation.start[0]}. CallerName: ${callerIdentifierName}, CallerModule: ${callerModuleName}`, "warn");
540
+ }
541
+ }
542
+ });
543
+ } catch (fileReadError) {
544
+ logToStderr(`[DepGraph]: Error reading/parsing file ${absoluteFilePath}: ${fileReadError.message}`, "error");
545
+ }
546
+ }
547
+ } else if (usagesResponse.resultType === "error") {
548
+ // logToStderr(`[DepGraph]: Could not get usages for ${sourceDeclNode.id}: ${JSON.stringify(usagesResponse.result)}`, "warn");
549
+ }
550
+ } catch (error) {
551
+ logToStderr(`[DepGraph]: Error processing usages for ${sourceDeclNode.id}: ${error.message}`, "error");
552
+ } finally {
553
+ activePromises--;
554
+ processedDeclarations++;
555
+ if (processedDeclarations % 10 === 0 || processedDeclarations === graphNodesList.length) {
556
+ logToStderr(`[DepGraph]: Processed ${processedDeclarations}/${graphNodesList.length} declarations for usages...`, "debug");
557
+ }
558
+ }
559
+ };
560
+ processUsageQueue.push(taskFn);
561
+ while(processUsageQueue.length > 0 || activePromises > 0) {
562
+ while(processUsageQueue.length > 0 && activePromises < max_concurrent_requests) {
563
+ const taskToRun = processUsageQueue.shift();
564
+ if (taskToRun) taskToRun();
565
+ }
566
+ await delay(50);
567
+ }
568
+ }
569
+ logToStderr(`[DepGraph]: Dependency graph generation complete.`, "info");
570
+ const result = { graph_nodes: graphNodesList }; // graphNodesList is already the result
571
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
572
+ }
573
+
574
+
575
+ // --- Tool Definitions for 'tools/list' ---
576
+ // Updated based on user feedback
577
+ const TOOL_DEFINITIONS = [
578
+ {
579
+ name: "get_server_status",
580
+ description: "Check if IDE server processes are running to avoid resource conflicts. Shows status of Tree-sitter parser (lightweight code analysis) and purs IDE server (process for type checking). ALWAYS use this before starting new IDE servers to prevent running multiple processes simultaneously.",
581
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
582
+ },
583
+ {
584
+ name: "echo",
585
+ description: "Simple test tool that echoes back your input. Use to verify the MCP server is responding correctly.",
586
+ inputSchema: { type: "object", properties: { message: { type: "string"}}, required: ["message"], additionalProperties: false },
587
+ },
588
+ {
589
+ name: "query_purescript_ast",
590
+ description: "[DEPRECATED] Parses PureScript code and executes a Tree-sitter query against its AST. Prefer specific AST query tools.",
591
+ inputSchema: { type: "object", properties: { purescript_code: { type: "string" }, tree_sitter_query: { type: "string" }}, required: ["purescript_code", "tree_sitter_query"], additionalProperties: false },
592
+ },
593
+ // --- Phase 1: Core AST Query Tools ---
594
+ // Module Information
595
+ {
596
+ name: "getModuleName",
597
+ description: "Extract the module name (like 'Data.List' or 'Main') from PureScript code. Works on files or code snippets without needing the IDE server. Useful for understanding code structure.",
598
+ inputSchema: {
599
+ type: "object",
600
+ properties: {
601
+ filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." },
602
+ code: { type: "string", description: "PureScript code string." }
603
+ },
604
+ additionalProperties: false,
605
+ description: "Exactly one of 'filePath' or 'code' must be provided."
606
+ }
607
+ },
608
+ {
609
+ name: "getImports",
610
+ description: "Find all import statements in PureScript code (like 'import Data.List', 'import Prelude'). Shows what external modules the code depends on. Works without the IDE server.",
611
+ inputSchema: {
612
+ type: "object",
613
+ properties: {
614
+ filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." },
615
+ code: { type: "string", description: "PureScript code string." }
616
+ },
617
+ additionalProperties: false,
618
+ description: "Exactly one of 'filePath' or 'code' must be provided."
619
+ }
620
+ },
621
+ {
622
+ name: "getTopLevelDeclarationNames",
623
+ description: "List all main definitions in PureScript code: function names, data types, type classes, etc. Gets just the names (like 'myFunction', 'MyDataType'). Fast analysis without needing IDE server.",
624
+ inputSchema: {
625
+ type: "object",
626
+ properties: {
627
+ filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." },
628
+ code: { type: "string", description: "PureScript code string." }
629
+ },
630
+ additionalProperties: false,
631
+ description: "Exactly one of 'filePath' or 'code' must be provided."
632
+ }
633
+ },
634
+ // Function and Value Declarations
635
+ {
636
+ name: "getFunctionNames",
637
+ description: "Extract only function names from PureScript code snippets. Focuses specifically on functions, ignoring data types and classes. Quick analysis for code understanding.",
638
+ inputSchema: {
639
+ type: "object",
640
+ properties: { code: { type: "string", description: "PureScript code snippet." } },
641
+ required: ["code"],
642
+ additionalProperties: false
643
+ }
644
+ },
645
+ // Expressions and Literals
646
+ // Control Flow Analysis
647
+ {
648
+ name: "getWhereBindings",
649
+ description: "Find 'where' clauses in PureScript functions. These contain local helper functions and variables. Useful for understanding function implementation details.",
650
+ inputSchema: {
651
+ type: "object",
652
+ properties: { code: { type: "string", description: "PureScript code snippet." } },
653
+ required: ["code"],
654
+ additionalProperties: false
655
+ }
656
+ },
657
+ {
658
+ name: "getTopLevelDeclarations",
659
+ description: "Get detailed information about all main definitions in PureScript code: names, types (function/data/class), and full source code. Includes filtering options to find specific items. More comprehensive than getTopLevelDeclarationNames.",
660
+ inputSchema: {
661
+ type: "object",
662
+ properties: {
663
+ filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." },
664
+ code: { type: "string", description: "PureScript code string." },
665
+ filters: {
666
+ type: "object",
667
+ properties: {
668
+ name: { type: "string", description: "Regex to filter declarations by name." },
669
+ type: { type: "string", description: "Regex to filter declarations by their mapped type (e.g., DeclData, DeclValue)." },
670
+ value: { type: "string", description: "Regex to filter declarations by their full text value." }
671
+ },
672
+ additionalProperties: false,
673
+ description: "Optional filters to apply to the declarations."
674
+ }
675
+ },
676
+ additionalProperties: false,
677
+ description: "Exactly one of 'filePath' or 'code' must be provided. Filters are optional."
678
+ }
679
+ },
680
+ // End of Phase 1 tools
681
+ {
682
+ name: "start_purs_ide_server",
683
+ description: "Start the PureScript IDE server for type checking, auto-completion, and error detection. Automatically stops any existing server to prevent conflicts. Only run one at a time. Required for all pursIde* tools to work. Automatically selects a random available port to avoid conflicts - the port number is returned in the response. Only accepts absolute paths.",
684
+ inputSchema: {
685
+ type: "object",
686
+ properties: {
687
+ project_path: { type: "string", description: "Absolute path to the PureScript project directory. Only absolute paths are supported." },
688
+ output_directory: { type: "string", default: "output/" },
689
+ source_globs: { type: "array", items: { type: "string" }, default: ["src/**/*.purs", ".spago/*/*/src/**/*.purs", "test/**/*.purs"]},
690
+ log_level: { type: "string", enum: ["all", "debug", "perf", "none"], default: "none" }
691
+ },
692
+ required: ["project_path"],
693
+ additionalProperties: false
694
+ },
695
+ },
696
+ {
697
+ name: "stop_purs_ide_server",
698
+ description: "Stop the PureScript IDE server to free up system resources. Use when you're done with type checking or want to switch projects. All pursIde* tools will stop working after this.",
699
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
700
+ },
701
+ {
702
+ name: "query_purs_ide",
703
+ description: "Send raw commands to the PureScript IDE server. PREREQUISITE: IDE server must be running (use start_purs_ide_server first). Advanced tool - prefer specific pursIde* tools for common tasks.",
704
+ inputSchema: { type: "object", properties: { purs_ide_command: { type: "string" }, purs_ide_params: { type: "object" }}, required: ["purs_ide_command"], additionalProperties: false },
705
+ },
706
+ {
707
+ name: "generate_dependency_graph",
708
+ description: "Create a dependency graph showing which functions/types use which others in PureScript modules. PREREQUISITES: IDE server must be running and modules must be loaded. Useful for understanding code relationships and refactoring impact.",
709
+ inputSchema: {
710
+ type: "object",
711
+ properties: {
712
+ target_modules: { type: "array", items: { type: "string" }, description: "Array of module names." },
713
+ max_concurrent_requests: { type: "integer", description: "Max concurrent 'usages' requests.", default: 5 }
714
+ },
715
+ required: ["target_modules"],
716
+ additionalProperties: false
717
+ },
718
+ },
719
+ // --- purs ide direct command wrappers ---
720
+ {
721
+ name: "pursIdeLoad",
722
+ description: "Load PureScript modules into the IDE server for type checking and completions. PREREQUISITE: IDE server must be running. ALWAYS run this first after starting the IDE server before using other pursIde* tools.",
723
+ inputSchema: {
724
+ type: "object",
725
+ properties: {
726
+ modules: {
727
+ type: "array",
728
+ items: { type: "string" },
729
+ description: "Optional: specific modules to load. If omitted, attempts to load all compiled modules."
730
+ }
731
+ },
732
+ additionalProperties: false
733
+ }
734
+ },
735
+ {
736
+ name: "pursIdeType",
737
+ description: "Look up the type signature of functions, variables, or values in PureScript code. PREREQUISITES: IDE server running and modules loaded. Helpful for understanding what a function expects and returns.",
738
+ inputSchema: {
739
+ type: "object",
740
+ properties: {
741
+ search: { type: "string", description: "Identifier name to search for." },
742
+ filters: { type: "array", items: { type: "object" }, description: "Optional: Array of Filter objects." },
743
+ currentModule: { type: "string", description: "Optional: Current module context." }
744
+ },
745
+ required: ["search"],
746
+ additionalProperties: false
747
+ }
748
+ },
749
+ {
750
+ name: "pursIdeCwd",
751
+ description: "Get the current working directory that the IDE server is using. PREREQUISITE: IDE server must be running. Useful for understanding the project context.",
752
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
753
+ },
754
+ {
755
+ name: "pursIdeReset",
756
+ description: "Clear all loaded modules from the IDE server's memory. PREREQUISITE: IDE server must be running. Use when switching projects or after major code changes. You'll need to run pursIdeLoad again after this.",
757
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
758
+ },
759
+ {
760
+ name: "pursIdeQuit",
761
+ description: "Gracefully shut down the IDE server and free up resources. PREREQUISITE: IDE server must be running. Same effect as stop_purs_ide_server but uses the server's built-in quit command first.",
762
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
763
+ },
764
+ {
765
+ name: "pursIdeRebuild",
766
+ description: "Quickly recompile a single PureScript module and check for errors. PREREQUISITES: IDE server running and modules loaded. Much faster than full project rebuild. Use when editing code to get immediate feedback.",
767
+ inputSchema: {
768
+ type: "object",
769
+ properties: {
770
+ file: { type: "string", description: "Path to the module to rebuild, or 'data:' prefixed source code." },
771
+ actualFile: { type: "string", description: "Optional: Real path if 'file' is 'data:' or a temp file." },
772
+ codegen: { type: "array", items: { type: "string" }, description: "Optional: Codegen targets (e.g., 'js', 'corefn'). Defaults to ['js']." }
773
+ },
774
+ required: ["file"],
775
+ additionalProperties: false
776
+ }
777
+ },
778
+ {
779
+ name: "pursIdeUsages",
780
+ description: "Find everywhere a specific function, type, or value is used across the project. PREREQUISITES: IDE server running and modules loaded. Essential for refactoring - shows impact of changes. If you plan to refactor, get usages before refactoring so you can make changes to all places that function is used.",
781
+ inputSchema: {
782
+ type: "object",
783
+ properties: {
784
+ module: { type: "string", description: "Module where the identifier is defined." },
785
+ namespace: { type: "string", enum: ["value", "type", "kind"], description: "Namespace of the identifier." },
786
+ identifier: { type: "string", description: "The identifier to find usages for." }
787
+ },
788
+ required: ["module", "namespace", "identifier"],
789
+ additionalProperties: false
790
+ }
791
+ },
792
+ {
793
+ name: "pursIdeList",
794
+ description: "List available modules in the project or imports in a specific file. PREREQUISITES: IDE server running and modules loaded. Helps understand project structure and dependencies.",
795
+ inputSchema: {
796
+ type: "object",
797
+ properties: {
798
+ listType: { type: "string", enum: ["availableModules", "import"], description: "Type of list to retrieve." },
799
+ file: { type: "string", description: "Path to the .purs file (required for 'import' listType)." }
800
+ },
801
+ required: ["listType"],
802
+ additionalProperties: false
803
+ }
804
+ }
805
+ ];
806
+
807
+ // SERVER_CAPABILITIES.tools should remain an empty object {}
808
+ // The client will use 'tools/list' to get the full definitions.
809
+
810
+ // Map internal tool names to their handlers
811
+ const INTERNAL_TOOL_HANDLERS = {
812
+ "get_server_status": internalHandleGetServerStatus,
813
+ "echo": internalHandleEcho,
814
+ "query_purescript_ast": internalHandleQueryPurescriptAst,
815
+ "start_purs_ide_server": internalHandleStartPursIdeServer,
816
+ "stop_purs_ide_server": internalHandleStopPursIdeServer,
817
+ "query_purs_ide": internalHandleQueryPursIde,
818
+ "generate_dependency_graph": internalHandleGenerateDependencyGraph,
819
+ // --- Phase 1: Core AST Query Tool Handlers (to be added) ---
820
+ "getModuleName": async (args) => {
821
+ if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized.");
822
+ const code = await getCodeFromInput(args, true);
823
+ const tree = purescriptTsParser.parse(code);
824
+ // Corrected query to capture the full text of the qualified_module node
825
+ const query = new Query(PureScriptLanguage, `(purescript name: (qualified_module) @module.qname)`);
826
+ const captures = query.captures(tree.rootNode);
827
+ if (captures.length > 0 && captures[0].name === 'module.qname') {
828
+ // The text of the qualified_module node itself is the full module name
829
+ return { content: [{ type: "text", text: JSON.stringify(captures[0].node.text.replace(/\s+/g, ''), null, 2) }] };
830
+ }
831
+ return { content: [{ type: "text", text: JSON.stringify(null, null, 2) }] };
832
+ },
833
+ "getFunctionNames": async (args) => {
834
+ if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized.");
835
+ const code = await getCodeFromInput(args, false);
836
+ const tree = purescriptTsParser.parse(code);
837
+ const query = new Query(PureScriptLanguage, `(function name: (variable) @func.name)`);
838
+ const captures = query.captures(tree.rootNode);
839
+ const functionNames = captures.map(capture => capture.node.text);
840
+ return { content: [{ type: "text", text: JSON.stringify(functionNames, null, 2) }] };
841
+ },
842
+ // Stubs for other Phase 1 handlers - to be implemented
843
+ "getImports": async (args) => {
844
+ if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized.");
845
+ const code = await getCodeFromInput(args, true);
846
+ const tree = purescriptTsParser.parse(code);
847
+ const query = new Query(PureScriptLanguage, `(import module: (qualified_module) @import.path)`);
848
+ const captures = query.captures(tree.rootNode);
849
+
850
+ const imports = [];
851
+ for (const capture of captures) {
852
+ if (capture.name === 'import.path') {
853
+ const moduleNodes = capture.node.children.filter(child => child.type === 'module');
854
+ if (moduleNodes.length > 0) {
855
+ const fullPath = moduleNodes.map(n => n.text).join('.');
856
+ const moduleName = moduleNodes[0].text;
857
+ const submoduleName = moduleNodes.length > 1 ? moduleNodes[1].text : undefined;
858
+ imports.push({
859
+ module: moduleName,
860
+ submodule: submoduleName,
861
+ fullPath: fullPath
862
+ });
863
+ }
864
+ }
865
+ }
866
+ return { content: [{ type: "text", text: JSON.stringify(imports, null, 2) }] };
867
+ },
868
+ "getTopLevelDeclarationNames": async (args) => {
869
+ if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized.");
870
+ const code = await getCodeFromInput(args, true); // true for module-oriented
871
+ const tree = purescriptTsParser.parse(code);
872
+ const querySource = `
873
+ [
874
+ (function name: (variable) @name)
875
+ (data name: (type) @name)
876
+ (class_declaration (class_head (class_name (type) @name)))
877
+ (type_alias name: (type) @name)
878
+ (foreign_import name: (variable) @name)
879
+ (signature name: (variable) @name)
880
+ (class_instance (instance_name) @name)
881
+ (kind_value_declaration name: (type) @name)
882
+ ]
883
+ `;
884
+ const query = new Query(PureScriptLanguage, querySource);
885
+ const captures = query.captures(tree.rootNode);
886
+ const declarationNames = captures.map(capture => capture.node.text).filter(Boolean);
887
+ // Deduplicate names
888
+ const uniqueNames = [...new Set(declarationNames)];
889
+ return { content: [{ type: "text", text: JSON.stringify(uniqueNames, null, 2) }] };
890
+ },
891
+ "getIntegerLiterals": async (args) => {
892
+ if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized.");
893
+ const code = await getCodeFromInput(args, false);
894
+ const tree = purescriptTsParser.parse(code);
895
+ const integerLiterals = [];
896
+
897
+ const query = new Query(PureScriptLanguage, `(integer) @integer.literal`);
898
+ const captures = query.captures(tree.rootNode);
899
+
900
+ captures.forEach(capture => {
901
+ if (capture.name === 'integer.literal') {
902
+ integerLiterals.push(parseInt(capture.node.text, 10));
903
+ }
904
+ });
905
+ return { content: [{ type: "text", text: JSON.stringify(integerLiterals, null, 2) }] };
906
+ },
907
+ "getWhereBindings": async (args) => {
908
+ if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized.");
909
+ const code = await getCodeFromInput(args, false); // Snippet-oriented
910
+ const tree = purescriptTsParser.parse(code);
911
+ const whereClausesText = [];
912
+
913
+ // Query for 'where' keyword followed by a 'declarations' block within a function or let binding
914
+ const querySource = `
915
+ (function
916
+ (where) @where_keyword
917
+ (declarations) @declarations_block)
918
+ `;
919
+ // Also consider 'let' bindings with 'where' clauses, though less common for top-level 'where'
920
+ // (let_binding (where) @where_keyword (declarations) @declarations_block)
921
+
922
+ const query = new Query(PureScriptLanguage, querySource);
923
+ const matches = query.matches(tree.rootNode);
924
+
925
+ for (const match of matches) {
926
+ const whereKeywordNode = match.captures.find(c => c.name === 'where_keyword')?.node;
927
+ const declarationsNode = match.captures.find(c => c.name === 'declarations_block')?.node;
928
+
929
+ if (whereKeywordNode && declarationsNode) {
930
+ // Construct the text from "where" keyword to the end of the declarations block
931
+ // This requires careful handling of start and end positions if they are not contiguous in the source text string
932
+ // For simplicity, if they are siblings and in order, we can take text from start of 'where' to end of 'declarations'
933
+ // A safer way is to combine their individual texts if they represent the full conceptual block
934
+ const fullWhereClauseText = `${whereKeywordNode.text} ${declarationsNode.text}`;
935
+ whereClausesText.push(fullWhereClauseText.trim());
936
+ }
937
+ }
938
+ // Deduplicate, as some complex structures might yield multiple partial matches
939
+ const uniqueWhereClauses = [...new Set(whereClausesText)];
940
+ return { content: [{ type: "text", text: JSON.stringify(uniqueWhereClauses, null, 2) }] };
941
+ },
942
+ "getTopLevelDeclarations": async (args) => {
943
+ if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized.");
944
+ const code = await getCodeFromInput(args, true); // true for module-oriented
945
+ const tree = purescriptTsParser.parse(code);
946
+
947
+ const querySource = `
948
+ [
949
+ (function name: (variable) @name.function) @DeclValue
950
+ (data name: (type) @name.data_type) @DeclData
951
+ (class_declaration (class_head (class_name (type) @name.class))) @DeclClass
952
+ (type_alias name: (type) @name.type_alias) @DeclType
953
+ (newtype name: (type) @name.newtype) @DeclNewtype
954
+ (foreign_import name: (variable) @name.foreign) @DeclForeign
955
+ (signature name: (variable) @name.signature) @DeclSignature
956
+ (class_instance (instance_head (class_name) @name.instance_class (type_name)? @name.instance_type)) @DeclInstanceChain
957
+ (kind_value_declaration name: (type) @name.kind_sig) @DeclKindSignature
958
+ (derive_declaration) @DeclDerive
959
+ (type_role_declaration (type) @name.role_type (type_role)+ @name.role_value) @DeclRole
960
+ (operator_declaration (operator) @name.operator) @DeclFixity
961
+ ]
962
+ `;
963
+ const query = new Query(PureScriptLanguage, querySource);
964
+ const matches = query.matches(tree.rootNode);
965
+ const rawDeclarations = [];
966
+
967
+ for (const match of matches) {
968
+ const mainCapture = match.captures.find(c => c.name.startsWith("Decl"));
969
+ if (!mainCapture) continue;
970
+
971
+ const declNode = mainCapture.node;
972
+ const mappedType = mainCapture.name;
973
+ const value = declNode.text; // This is the full text of the declaration node
974
+
975
+ // Create a map of captures for efficient lookup
976
+ // Store the full capture object {name, node} as node properties (like .text) are needed
977
+ const allCapturesMap = new Map(match.captures.map(c => [c.name, c]));
978
+
979
+ let finalName;
980
+
981
+ // Prioritized list of capture names that directly provide the 'name'
982
+ const singleNameCaptureKeys = [
983
+ "name.function", "name.data_type", "name.class", "name.type_alias",
984
+ "name.newtype", "name.foreign", "name.signature", "name.kind_sig",
985
+ "name.role_type", "name.operator"
986
+ ];
987
+
988
+ let foundSingleName = false;
989
+ for (const key of singleNameCaptureKeys) {
990
+ if (allCapturesMap.has(key)) {
991
+ finalName = allCapturesMap.get(key).node.text;
992
+ foundSingleName = true;
993
+ break;
994
+ }
995
+ }
996
+
997
+ if (!foundSingleName) {
998
+ if (allCapturesMap.has("name.instance_class")) {
999
+ finalName = allCapturesMap.get("name.instance_class").node.text;
1000
+ if (allCapturesMap.has("name.instance_type")) {
1001
+ finalName += ` ${allCapturesMap.get("name.instance_type").node.text}`;
1002
+ }
1003
+ } else if (mappedType === "DeclDerive" || mappedType === "DeclFixity") {
1004
+ // declNode is mainCapture.node, which is already available
1005
+ const firstIdentNode = declNode.descendantsOfType("identifier")[0] ||
1006
+ declNode.descendantsOfType("type")[0] ||
1007
+ declNode.descendantsOfType("operator")[0];
1008
+ finalName = firstIdentNode ? firstIdentNode.text : `complex_${mappedType.toLowerCase().replace('decl', '')}`;
1009
+ } else {
1010
+ finalName = "unknown"; // Default if no other specific name found
1011
+ }
1012
+ }
1013
+
1014
+ rawDeclarations.push({ name: finalName, type: mappedType, value, treeSitterType: declNode.type }); // Removed node property
1015
+ }
1016
+
1017
+ let declarations = rawDeclarations; // Use rawDeclarations directly without consolidation
1018
+
1019
+ // Apply filters if provided
1020
+ if (args.filters) {
1021
+ const { name, type, value } = args.filters;
1022
+ if (name) {
1023
+ const nameRegex = new RegExp(name);
1024
+ declarations = declarations.filter(d => nameRegex.test(d.name));
1025
+ }
1026
+ if (type) {
1027
+ const typeRegex = new RegExp(type);
1028
+ declarations = declarations.filter(d => typeRegex.test(d.type));
1029
+ }
1030
+ if (value) {
1031
+ const valueRegex = new RegExp(value);
1032
+ declarations = declarations.filter(d => valueRegex.test(d.value));
1033
+ }
1034
+ }
1035
+
1036
+ return { content: [{ type: "text", text: JSON.stringify(declarations, null, 2) }] };
1037
+ },
1038
+ // --- New purs ide command wrapper handlers ---
1039
+ "pursIdeLoad": async (args) => {
1040
+ const params = args || {}; // If args is null/undefined, pass empty object for default load all
1041
+ const result = await sendCommandToPursIde({ command: "load", params });
1042
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1043
+ },
1044
+ "pursIdeType": async (args) => {
1045
+ if (!args || typeof args.search !== 'string') {
1046
+ throw new Error("Invalid input: 'search' (string) is required for pursIdeType.");
1047
+ }
1048
+ const params = {
1049
+ search: args.search,
1050
+ filters: args.filters || [], // Default to empty filters array
1051
+ currentModule: args.currentModule
1052
+ };
1053
+ const result = await sendCommandToPursIde({ command: "type", params });
1054
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1055
+ },
1056
+ "pursIdeCwd": async () => {
1057
+ const result = await sendCommandToPursIde({ command: "cwd" });
1058
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1059
+ },
1060
+ "pursIdeReset": async () => {
1061
+ const result = await sendCommandToPursIde({ command: "reset" });
1062
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1063
+ },
1064
+ "pursIdeQuit": async () => {
1065
+ let quitMessage = "purs ide server quit command initiated.";
1066
+ let pursIdeResponded = false;
1067
+
1068
+ if (pursIdeProcess && pursIdeIsReady) {
1069
+ logToStderr("[pursIdeQuit] Attempting to send 'quit' command to purs ide server.", "debug");
1070
+ sendCommandToPursIde({ command: "quit" })
1071
+ .then(res => {
1072
+ pursIdeResponded = true;
1073
+ logToStderr(`[pursIdeQuit] purs ide server responded to quit command: ${JSON.stringify(res)}`, 'debug');
1074
+ })
1075
+ .catch(err => {
1076
+ logToStderr(`[pursIdeQuit] Error/No response from purs ide server for quit command: ${err.message}`, 'warn');
1077
+ });
1078
+
1079
+ // Wait a short period to allow purs ide server to shut down gracefully
1080
+ // or for the sendCommandToPursIde to potentially resolve/reject.
1081
+ await delay(250); // Increased slightly to 250ms
1082
+ } else {
1083
+ quitMessage = "No purs ide server was running or ready to send quit command to.";
1084
+ logToStderr("[pursIdeQuit] " + quitMessage, "info");
1085
+ }
1086
+
1087
+ // Ensure our managed process is stopped regardless of purs ide's response
1088
+ if (pursIdeProcess) {
1089
+ logToStderr("[pursIdeQuit] Ensuring managed purs ide process is stopped.", "debug");
1090
+ pursIdeProcess.kill();
1091
+ pursIdeProcess = null;
1092
+ pursIdeIsReady = false;
1093
+ logPursIdeOutput("Managed purs ide server process stopped via pursIdeQuit tool.", "info");
1094
+ quitMessage += " Managed purs ide process has been stopped.";
1095
+ } else {
1096
+ if (!quitMessage.includes("No purs ide server was running")) {
1097
+ quitMessage += " No managed purs ide process was found running to stop.";
1098
+ }
1099
+ }
1100
+
1101
+ if (pursIdeResponded) {
1102
+ quitMessage += " purs ide server acknowledged quit.";
1103
+ } else {
1104
+ quitMessage += " purs ide server may not have acknowledged quit before process termination.";
1105
+ }
1106
+
1107
+ return { content: [{ type: "text", text: JSON.stringify({ status_message: quitMessage, resultType: "success" }, null, 2) }] };
1108
+ },
1109
+ "pursIdeRebuild": async (args) => {
1110
+ if (!args || typeof args.file !== 'string') {
1111
+ throw new Error("Invalid input: 'file' (string) is required for pursIdeRebuild.");
1112
+ }
1113
+ const params = {
1114
+ file: args.file,
1115
+ actualFile: args.actualFile,
1116
+ codegen: args.codegen // purs ide server defaults to js if undefined
1117
+ };
1118
+ const result = await sendCommandToPursIde({ command: "rebuild", params });
1119
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1120
+ },
1121
+ "pursIdeUsages": async (args) => {
1122
+ if (!args || typeof args.module !== 'string' || typeof args.namespace !== 'string' || typeof args.identifier !== 'string') {
1123
+ throw new Error("Invalid input: 'module', 'namespace', and 'identifier' (strings) are required for pursIdeUsages.");
1124
+ }
1125
+ const params = {
1126
+ module: args.module,
1127
+ namespace: args.namespace,
1128
+ identifier: args.identifier
1129
+ };
1130
+ const result = await sendCommandToPursIde({ command: "usages", params });
1131
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1132
+ },
1133
+ "pursIdeList": async (args) => {
1134
+ if (!args || typeof args.listType !== 'string') {
1135
+ throw new Error("Invalid input: 'listType' (string) is required for pursIdeList.");
1136
+ }
1137
+ const params = { type: args.listType };
1138
+ if (args.listType === "import") {
1139
+ if (typeof args.file !== 'string') {
1140
+ throw new Error("'file' (string) is required when listType is 'import'.");
1141
+ }
1142
+ params.file = args.file;
1143
+ }
1144
+ const result = await sendCommandToPursIde({ command: "list", params });
1145
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1146
+ }
1147
+ };
1148
+
1149
+
1150
+ // --- MCP Stdio Protocol Handling ---
1151
+ function createSuccessResponse(id, result) {
1152
+ return { jsonrpc: '2.0', id, result };
1153
+ }
1154
+
1155
+ function createErrorResponse(id, code, message, data = undefined) {
1156
+ return { jsonrpc: '2.0', id, error: { code, message, data } };
1157
+ }
1158
+
1159
+ // Updated handleMcpRequest based on user feedback
1160
+ async function handleMcpRequest(request) {
1161
+ const { method, params, id } = request;
1162
+
1163
+ try {
1164
+ if (method === 'initialize') {
1165
+ logToStderr(`Received initialize request from client (id: ${id}). Params: ${JSON.stringify(params)}`, 'info');
1166
+ return createSuccessResponse(id, {
1167
+ protocolVersion: '2024-11-05',
1168
+ serverInfo: SERVER_INFO,
1169
+ capabilities: SERVER_CAPABILITIES // SERVER_CAPABILITIES.tools is now correctly an empty object
1170
+ });
1171
+ }
1172
+
1173
+ if (method === 'initialized' || method === 'notifications/initialized') {
1174
+ logToStderr(`Received initialized notification from client. Params: ${JSON.stringify(params)}`, 'info');
1175
+ return null;
1176
+ }
1177
+
1178
+ if (method === 'tools/list') {
1179
+ const toolsToExclude = ['query_purescript_ast', 'query_purs_ide']; // Keep query_purs_ide for now, for direct access if needed
1180
+ const filteredToolDefinitions = TOOL_DEFINITIONS.filter(
1181
+ tool => !toolsToExclude.includes(tool.name)
1182
+ );
1183
+ return createSuccessResponse(id, { tools: filteredToolDefinitions });
1184
+ }
1185
+
1186
+ if (method === 'tools/call') {
1187
+ if (!params || typeof params.name !== 'string') {
1188
+ return createErrorResponse(id, -32602, "Invalid params: 'name' of tool is required for tools/call.");
1189
+ }
1190
+ const toolName = params.name;
1191
+ const toolArgs = params.arguments || {};
1192
+
1193
+ const handler = INTERNAL_TOOL_HANDLERS[toolName];
1194
+ if (!handler) {
1195
+ return createErrorResponse(id, -32601, `Method not found (tool): ${toolName}`);
1196
+ }
1197
+
1198
+ const result = await handler(toolArgs); // This now returns { content: [...] }
1199
+ return createSuccessResponse(id, result); // The entire { content: [...] } is the result for tools/call
1200
+ }
1201
+
1202
+ if (method === 'resources/list') {
1203
+ return createSuccessResponse(id, { resources: [] });
1204
+ }
1205
+
1206
+ if (method === 'resources/templates/list') {
1207
+ return createSuccessResponse(id, { resourceTemplates: [] });
1208
+ }
1209
+
1210
+ if (method === 'resources/read') {
1211
+ return createErrorResponse(id, -32601, "No resources available to read");
1212
+ }
1213
+
1214
+ return createErrorResponse(id, -32601, `Method not found: ${method}`);
1215
+
1216
+ } catch (error) {
1217
+ logToStderr(`Error handling MCP request (method: ${method}, id: ${id}): ${error.message}\n${error.stack}`, 'error');
1218
+ return createErrorResponse(id, -32000, `Server error: ${error.message}`, { stack: error.stack });
1219
+ }
1220
+ }
1221
+
1222
+ // Updated rl.on('line') handler based on user feedback
1223
+ const rl = readline.createInterface({
1224
+ input: process.stdin,
1225
+ output: process.stdout,
1226
+ terminal: false
1227
+ });
1228
+
1229
+ rl.on('line', async (line) => {
1230
+ logToStderr(`Received line: ${line.substring(0, 200)}...`, 'debug');
1231
+ let request;
1232
+ try {
1233
+ if (line.trim() === '') return;
1234
+ request = JSON.parse(line);
1235
+ } catch (e) {
1236
+ const errResp = createErrorResponse(null, -32700, 'Parse error', { details: e.message, receivedLine: line });
1237
+ process.stdout.write(JSON.stringify(errResp) + '\n');
1238
+ return;
1239
+ }
1240
+
1241
+ if (typeof request.method !== 'string') {
1242
+ const errResp = createErrorResponse(request.id || null, -32600, 'Invalid Request: method must be a string.');
1243
+ process.stdout.write(JSON.stringify(errResp) + '\n');
1244
+ return;
1245
+ }
1246
+
1247
+ const response = await handleMcpRequest(request);
1248
+
1249
+ if (response !== null && response !== undefined) {
1250
+ process.stdout.write(JSON.stringify(response) + '\n');
1251
+ logToStderr(`Sent response for id ${response.id}: ${JSON.stringify(response).substring(0,200)}...`, 'debug');
1252
+ } else {
1253
+ logToStderr(`Handled notification (method: ${request.method}). No response sent.`, 'debug');
1254
+ }
1255
+ });
1256
+
1257
+ rl.on('close', () => {
1258
+ logToStderr("Stdin closed. Exiting.", "info");
1259
+ if (pursIdeProcess) {
1260
+ logToStderr("Stopping active purs ide server due to stdin close.", "warn");
1261
+ pursIdeProcess.kill();
1262
+ pursIdeProcess = null;
1263
+ }
1264
+ process.exit(0);
1265
+ });
1266
+
1267
+ // Graceful shutdown signals
1268
+ const shutdown = (signal) => {
1269
+ logToStderr(`Received ${signal}. MCP Server shutting down...`, 'info');
1270
+ if (pursIdeProcess) {
1271
+ logToStderr("Stopping active purs ide server due to shutdown signal.", "warn");
1272
+ pursIdeProcess.kill();
1273
+ }
1274
+ process.exit(0);
1275
+ };
1276
+ process.on('SIGINT', () => shutdown('SIGINT'));
1277
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1278
+
1279
+
1280
+ async function main() {
1281
+ logToStderr("PureScript MCP Stdio Server (JSON-RPC) starting...", "info");
1282
+ await initializeTreeSitterParser();
1283
+ logToStderr("Ready to process JSON-RPC commands from stdin.", "info");
1284
+ // Server waits for the client to send the first 'initialize' request.
1285
+ }
1286
+
1287
+ main();