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/run_tests.js ADDED
@@ -0,0 +1,677 @@
1
+ const { spawn } = require('child_process');
2
+ const readline = require('readline');
3
+ const chalk = require('chalk'); // Assuming chalk@4.1.2 is installed
4
+ const path = require('path');
5
+
6
+ const TEST_PROJECT_PATH = path.resolve(__dirname, './purescript-test-examples');
7
+
8
+ let mcpServerProcess;
9
+ let mcpRl;
10
+ let pendingRequests = new Map();
11
+ let nextRequestId = 1;
12
+ let serverInitialized = false;
13
+
14
+ let testsPassed = 0;
15
+ let testsFailed = 0;
16
+
17
+ function logTest(message, level = 'info') {
18
+ const timestamp = new Date().toISOString();
19
+ let coloredMessage = message;
20
+ switch (level) {
21
+ case 'error': coloredMessage = chalk.redBright(`[${timestamp}] [TEST-ERROR] ${message}`); break;
22
+ case 'warn': coloredMessage = chalk.yellowBright(`[${timestamp}] [TEST-WARN] ${message}`); break;
23
+ case 'info': coloredMessage = chalk.blueBright(`[${timestamp}] [TEST-INFO] ${message}`); break;
24
+ case 'debug': coloredMessage = chalk.gray(`[${timestamp}] [TEST-DEBUG] ${message}`); break;
25
+ default: coloredMessage = `[${timestamp}] [TEST] ${message}`;
26
+ }
27
+ console.log(coloredMessage);
28
+ }
29
+
30
+ function startMcpServer() {
31
+ return new Promise((resolve, reject) => {
32
+ logTest('Starting MCP server process (node index.js)...', 'info');
33
+ mcpServerProcess = spawn('node', ['index.js'], { stdio: ['pipe', 'pipe', 'pipe'] });
34
+
35
+ mcpServerProcess.stderr.on('data', (data) => {
36
+ logTest(`MCP Server STDERR: ${data.toString().trim()}`, 'warn');
37
+ });
38
+
39
+ mcpRl = readline.createInterface({ input: mcpServerProcess.stdout });
40
+
41
+ mcpRl.on('line', (line) => {
42
+ logTest(`MCP Server STDOUT: ${line.substring(0, 300)}${line.length > 300 ? '...' : ''}`, 'debug');
43
+ try {
44
+ const response = JSON.parse(line);
45
+ if (response.id && pendingRequests.has(response.id)) {
46
+ const { resolve: reqResolve, reject: reqReject } = pendingRequests.get(response.id);
47
+ pendingRequests.delete(response.id);
48
+ if (response.error) {
49
+ logTest(`MCP Error for ID ${response.id}: ${JSON.stringify(response.error)}`, 'error');
50
+ reqReject(new Error(`MCP Error ${response.error.code}: ${response.error.message}${response.error.data ? ` - ${JSON.stringify(response.error.data)}` : ''}`));
51
+ } else {
52
+ reqResolve(response.result);
53
+ }
54
+ } else {
55
+ logTest(`Received response for unknown or already handled ID: ${response.id}`, 'warn');
56
+ }
57
+ } catch (e) {
58
+ logTest(`Failed to parse JSON response from server: ${line} - ${e.message}`, 'error');
59
+ }
60
+ });
61
+
62
+ mcpServerProcess.on('exit', (code) => {
63
+ logTest(`MCP server process exited with code ${code}`, code === 0 ? 'info' : 'error');
64
+ mcpServerProcess = null;
65
+ pendingRequests.forEach(({ reject: reqReject }) => reqReject(new Error('MCP server exited prematurely.')));
66
+ pendingRequests.clear();
67
+ });
68
+
69
+ mcpServerProcess.on('error', (err) => {
70
+ logTest(`Failed to start MCP server process: ${err.message}`, 'error');
71
+ reject(err);
72
+ });
73
+
74
+ // Give the server a moment to start, then resolve
75
+ setTimeout(() => {
76
+ logTest('MCP server process spawned.', 'info');
77
+ resolve();
78
+ }, 1000); // Wait for server to be ready to accept initialize
79
+ });
80
+ }
81
+
82
+ async function initializeMcpServer() {
83
+ if (!mcpServerProcess) throw new Error("MCP Server not started.");
84
+ if (serverInitialized) return;
85
+
86
+ logTest('Sending "initialize" request to MCP server...', 'info');
87
+ const initResponse = await callMcpToolRaw({
88
+ jsonrpc: '2.0',
89
+ id: nextRequestId++,
90
+ method: 'initialize',
91
+ params: {
92
+ processId: process.pid,
93
+ clientInfo: { name: 'run_tests.js', version: '1.0.0' },
94
+ capabilities: {}
95
+ }
96
+ });
97
+ assert(initResponse && initResponse.protocolVersion, 'Server responded to initialize.', 'MCP Initialize');
98
+ if (initResponse && initResponse.protocolVersion) {
99
+ serverInitialized = true;
100
+ logTest('MCP Server initialized successfully.', 'info');
101
+ // Send 'initialized' notification (no response expected)
102
+ sendMcpNotification({
103
+ jsonrpc: '2.0',
104
+ method: 'initialized',
105
+ params: {}
106
+ });
107
+ } else {
108
+ throw new Error('MCP Server initialization failed.');
109
+ }
110
+ }
111
+
112
+ function sendMcpNotification(requestPayload) {
113
+ if (!mcpServerProcess || !mcpServerProcess.stdin.writable) {
114
+ logTest('MCP server stdin not writable for notification.', 'error');
115
+ return;
116
+ }
117
+ const message = JSON.stringify(requestPayload);
118
+ logTest(`Sending MCP Notification: ${message.substring(0,200)}...`, 'debug');
119
+ mcpServerProcess.stdin.write(message + '\n');
120
+ }
121
+
122
+
123
+ function callMcpToolRaw(requestPayload) { // For initialize, etc.
124
+ return new Promise((resolve, reject) => {
125
+ if (!mcpServerProcess || !mcpServerProcess.stdin.writable) {
126
+ return reject(new Error('MCP server not running or stdin not writable.'));
127
+ }
128
+ pendingRequests.set(requestPayload.id, { resolve, reject });
129
+ const message = JSON.stringify(requestPayload);
130
+ logTest(`Sending MCP Request (ID ${requestPayload.id}): ${message.substring(0,300)}${message.length > 300 ? '...' : ''}`, 'debug');
131
+ mcpServerProcess.stdin.write(message + '\n');
132
+ });
133
+ }
134
+
135
+ async function callMcpTool(toolName, toolArgs = {}) {
136
+ if (!serverInitialized) {
137
+ throw new Error("MCP Server not initialized. Call initializeMcpServer first.");
138
+ }
139
+ console.log(chalk.blue(`\n--- Testing MCP Tool: ${toolName} ---`));
140
+ console.log(chalk.dim(`Arguments: ${JSON.stringify(toolArgs)}`));
141
+
142
+ const requestId = nextRequestId++;
143
+ const payload = {
144
+ jsonrpc: '2.0',
145
+ id: requestId,
146
+ method: 'tools/call',
147
+ params: {
148
+ name: toolName,
149
+ arguments: toolArgs
150
+ }
151
+ };
152
+ try {
153
+ const result = await callMcpToolRaw(payload);
154
+ // The 'result' from tools/call is an object like { content: [{ type: "text", text: "..." }] }
155
+ // We need to parse the 'text' field if it's JSON.
156
+ if (result && result.content && Array.isArray(result.content) && result.content.length > 0 && result.content[0].type === 'text') {
157
+ const textContent = result.content[0].text;
158
+ try {
159
+ const parsedText = JSON.parse(textContent);
160
+ console.log(chalk.dim(`Parsed Tool Response: ${JSON.stringify(parsedText).substring(0, 200)}...`));
161
+ return parsedText; // Return the parsed content of the "text" field
162
+ } catch (e) {
163
+ console.log(chalk.dim(`Tool Response (not JSON): ${textContent.substring(0, 200)}...`));
164
+ return textContent; // Return as string if not JSON
165
+ }
166
+ }
167
+ console.log(chalk.dim(`Raw Tool Response: ${JSON.stringify(result).substring(0, 200)}...`));
168
+ return result; // Fallback to returning the whole result object
169
+ } catch (error) {
170
+ console.error(chalk.red(`Error during ${toolName} test: ${error.message}`));
171
+ return { error: error.message }; // Ensure errors are returned as objects
172
+ }
173
+ }
174
+
175
+
176
+ function assert(condition, message, testName) {
177
+ if (condition) {
178
+ console.log(chalk.green(`[PASS] ${testName}: ${message}`));
179
+ testsPassed++;
180
+ } else {
181
+ console.error(chalk.red(`[FAIL] ${testName}: ${message}`));
182
+ testsFailed++;
183
+ }
184
+ }
185
+
186
+ function assertDeepEqual(actual, expected, message, testName) {
187
+ const actualJson = JSON.stringify(actual);
188
+ const expectedJson = JSON.stringify(expected);
189
+ if (actualJson === expectedJson) {
190
+ assert(true, message, testName);
191
+ } else {
192
+ assert(false, `${message} - Expected: ${expectedJson}, Got: ${actualJson}`, testName);
193
+ }
194
+ }
195
+
196
+
197
+ async function runAstToolTests() {
198
+ logTest("--- Running AST Tool Tests ---", "info");
199
+
200
+ const moduleCode = `
201
+ module Test.MyModule where
202
+ import Prelude
203
+ main = pure unit
204
+ `;
205
+ const functionsCode = `
206
+ module Test.MyModule where
207
+ import Prelude
208
+ myFunction :: Int -> Int
209
+ myFunction x = x + 1
210
+ anotherFunction :: String -> String
211
+ anotherFunction s = s <> "!"
212
+ main = pure unit
213
+ `;
214
+ const typeSigsCode = `
215
+ module Test.MyModule where
216
+ import Prelude
217
+ myFunction :: Int -> Int
218
+ myFunction x = x + 1
219
+ anotherFunction :: String -> String
220
+ anotherFunction s = s <> "!"
221
+ main :: Effect Unit
222
+ main = pure unit
223
+ `;
224
+ const letBindingsCode = `
225
+ module Test.MyModule where
226
+ import Prelude
227
+ myFunction :: Int -> Int
228
+ myFunction x =
229
+ let y = x + 1
230
+ z = y * 2
231
+ in z
232
+ main = pure unit
233
+ `;
234
+ const dataTypesCode = `
235
+ module Test.MyModule where
236
+ import Prelude
237
+ data MyType = MyConstructor Int | AnotherConstructor String
238
+ data AnotherType a = GenericConstructor a
239
+ main = pure unit
240
+ `;
241
+ const typeClassesCode = `
242
+ module Test.MyModule where
243
+ import Prelude
244
+ class MyShow a where
245
+ myShow :: a -> String
246
+ class (MyOrd a) <= MyEq a where
247
+ myEq :: a -> a -> Boolean
248
+ main = pure unit
249
+ `;
250
+ const instancesCode = `
251
+ module Test.MyModule where
252
+ import Prelude
253
+ class MyShow a where
254
+ myShow :: a -> String
255
+ instance myShowInt :: MyShow Int where
256
+ myShow _ = "Int"
257
+ instance MyShow String where -- Anonymous instance
258
+ myShow s = s
259
+ main = pure unit
260
+ `;
261
+ const typeAliasesCode = `
262
+ module Test.MyModule where
263
+ import Prelude
264
+ import Data.Map (Map)
265
+
266
+ type MyString = String
267
+ type MyRecord = { foo :: Int, bar :: String }
268
+ type MyMap k v = Map k v
269
+ type MyParameterizedRecord a = { value :: a, label :: String }
270
+
271
+ main = pure unit
272
+ `;
273
+ const stringLiteralsCode = `
274
+ module Test.MyModule where
275
+ import Prelude
276
+ myString = "hello"
277
+ anotherString = "world"
278
+ main = pure unit
279
+ `;
280
+ const integerLiteralsCode = `
281
+ module Test.MyModule where
282
+ import Prelude
283
+ myInt = 123
284
+ anotherInt = 456
285
+ main = pure unit
286
+ `;
287
+ const varRefsCode = `
288
+ module Test.MyModule where
289
+ import Prelude
290
+ foo = 1
291
+ bar = foo + 2
292
+ baz = bar * foo
293
+ main = pure unit
294
+ `;
295
+ const recordFieldsCode = `
296
+ module Test.MyModule where
297
+ import Prelude
298
+ myRecord = { label: "test", value: 1 }
299
+ accessor r = r.label
300
+ main = pure unit
301
+ `;
302
+ const casePatternsCode = `
303
+ module Test.MyModule where
304
+ import Prelude
305
+ data MyType = Con1 Int | Con2 String
306
+ myFunction :: MyType -> String
307
+ myFunction x = case x of
308
+ Con1 i -> "Number: " <> show i
309
+ Con2 s -> "String: " <> s
310
+ _ -> "Other"
311
+ main = pure unit
312
+ `;
313
+ const doBindingsCode = `
314
+ module Test.MyModule where
315
+ import Prelude
316
+ import Effect (Effect)
317
+ import Effect.Console (log)
318
+ main :: Effect Unit
319
+ main = do
320
+ let x = 1
321
+ y <- pure 2
322
+ log "hello"
323
+ let z = x + y
324
+ pure unit
325
+ `;
326
+ const whereBindingsCode = `
327
+ module Test.MyModule where
328
+ import Prelude
329
+ myFunction :: Int -> Int
330
+ myFunction x = result
331
+ where
332
+ intermediate = x + 1
333
+ result = intermediate * 2
334
+ main = pure unit
335
+ `;
336
+
337
+ const getTopLevelDeclarationsTestCode = `
338
+ module Test.Declarations where
339
+
340
+ import Prelude
341
+ import Effect (Effect)
342
+
343
+ data MyData = ConstructorA | ConstructorB String
344
+
345
+ type MyAlias = Int
346
+
347
+ myFunction :: Int -> Int
348
+ myFunction x = x + 1
349
+
350
+ anotherFunction :: String -> Effect Unit
351
+ anotherFunction _ = pure unit
352
+
353
+ class MySimpleClass a where
354
+ mySimpleMethod :: a -> a
355
+
356
+ instance mySimpleClassInt :: MySimpleClass Int where
357
+ mySimpleMethod x = x
358
+
359
+ foreign import data ForeignDataType :: Type
360
+ foreign import foreignFunc :: Int -> String
361
+ type role MyData representational
362
+ infix 6 type Tuple as /\\
363
+ `;
364
+
365
+ const topLevelDeclarationsCode = `
366
+ module Test.TopLevel where
367
+ import Prelude
368
+ import Effect (Effect)
369
+
370
+ foreign import data MyForeignData :: Type
371
+
372
+ foreign import myForeignFunction :: Int -> Effect String
373
+
374
+ data MyData = Constructor1 | Constructor2 Int
375
+
376
+ type MyTypeAlias = String
377
+
378
+ class MyClass a where
379
+ classMethod :: a -> Boolean
380
+
381
+ instance myClassInt :: MyClass Int where
382
+ classMethod _ = true
383
+
384
+ myFunction :: Int -> String
385
+ myFunction _ = "hello"
386
+
387
+ anotherFunction :: Effect Unit
388
+ anotherFunction = pure unit
389
+ `;
390
+
391
+ // 1. getModuleName
392
+ let testResult = await callMcpTool('getModuleName', { code: moduleCode });
393
+ assertDeepEqual(testResult, "Test.MyModule", 'getModuleName returns full module name.', 'AST - getModuleName');
394
+
395
+ // 2. getImports
396
+ testResult = await callMcpTool('getImports', { code: moduleCode });
397
+ assertDeepEqual(testResult, [{ module: "Prelude", fullPath: "Prelude" }], 'getImports finds Prelude.', 'AST - getImports');
398
+
399
+ // Test getTopLevelDeclarationNames
400
+ testResult = await callMcpTool('getTopLevelDeclarationNames', { code: topLevelDeclarationsCode });
401
+ const expectedTopLevelNames = [
402
+ "MyForeignData", // from: foreign import data MyForeignData :: Type (kind_value_declaration)
403
+ "myForeignFunction", // from: foreign import myForeignFunction :: Int -> Effect String
404
+ "MyData", // from: data MyData = ...
405
+ "MyTypeAlias", // from: type MyTypeAlias = String
406
+ "MyClass", // from: class MyClass a where ...
407
+ "classMethod", // from: classMethod :: a -> Boolean (signature within class)
408
+ "myClassInt", // from: instance myClassInt :: MyClass Int where ...
409
+ "myFunction", // from: myFunction :: Int -> String
410
+ "anotherFunction" // from: anotherFunction :: Effect Unit
411
+ ].sort();
412
+ // Ensure the result contains all expected names and no extras, order-independent
413
+ if (testResult && Array.isArray(testResult)) {
414
+ assert(testResult.sort().join(',') === expectedTopLevelNames.join(','),
415
+ `getTopLevelDeclarationNames returns correct names. Expected: ${expectedTopLevelNames.join(', ')}, Got: ${testResult.sort().join(', ')}`,
416
+ 'AST - getTopLevelDeclarationNames');
417
+ } else {
418
+ assert(false,
419
+ `getTopLevelDeclarationNames failed or returned unexpected type. Expected array, Got: ${JSON.stringify(testResult)}`,
420
+ 'AST - getTopLevelDeclarationNames');
421
+ }
422
+
423
+ // Test getTopLevelDeclarations (new comprehensive tool)
424
+ let declsResult = await callMcpTool('getTopLevelDeclarations', { code: getTopLevelDeclarationsTestCode });
425
+ assert(declsResult && Array.isArray(declsResult), 'getTopLevelDeclarations returns an array.', 'AST - getTopLevelDeclarations - Basic');
426
+ if (declsResult && Array.isArray(declsResult)) {
427
+ const expectedDeclCount = 14; // MyData, MyAlias, myFunction sig, myFunction val, anotherFunction sig, anotherFunction val, MySimpleClass, mySimpleMethod sig, mySimpleClassInt inst, ForeignDataType, foreignFunc, MyData role, Tuple infix
428
+ assert(declsResult.length === expectedDeclCount, `getTopLevelDeclarations finds ${expectedDeclCount} declarations. Found: ${declsResult.length}`, 'AST - getTopLevelDeclarations - Count');
429
+
430
+ const myFuncDecl = declsResult.find(d => d.name === 'myFunction' && d.type === 'DeclValue');
431
+ assert(myFuncDecl && myFuncDecl.value.includes('myFunction x = x + 1'), 'getTopLevelDeclarations finds myFunction (DeclValue).', 'AST - getTopLevelDeclarations - myFunction');
432
+
433
+ const myDataDecl = declsResult.find(d => d.name === 'MyData' && d.type === 'DeclData');
434
+ assert(myDataDecl && myDataDecl.value.includes('data MyData = ConstructorA | ConstructorB String'), 'getTopLevelDeclarations finds MyData (DeclData).', 'AST - getTopLevelDeclarations - MyData');
435
+
436
+ const myAliasDecl = declsResult.find(d => d.name === 'MyAlias' && d.type === 'DeclType'); // type_alias maps to DeclType
437
+ assert(myAliasDecl && myAliasDecl.value.includes('type MyAlias = Int'), 'getTopLevelDeclarations finds MyAlias (DeclType).', 'AST - getTopLevelDeclarations - MyAlias');
438
+
439
+ const myClassDecl = declsResult.find(d => d.name === 'MySimpleClass' && d.type === 'DeclClass');
440
+ assert(myClassDecl && myClassDecl.value.includes('class MySimpleClass a where'), 'getTopLevelDeclarations finds MySimpleClass (DeclClass).', 'AST - getTopLevelDeclarations - MySimpleClass');
441
+
442
+ const myInstanceDecl = declsResult.find(d => d.name.startsWith('MySimpleClass Int') && d.type === 'DeclInstanceChain');
443
+ assert(myInstanceDecl && myInstanceDecl.value.includes('instance mySimpleClassInt :: MySimpleClass Int where'), 'getTopLevelDeclarations finds MySimpleClass Int instance (DeclInstanceChain).', 'AST - getTopLevelDeclarations - MySimpleClass Instance');
444
+
445
+ const foreignDataDecl = declsResult.find(d => d.name === 'ForeignDataType' && d.type === 'DeclKindSignature'); // foreign import data is a kind signature
446
+ assert(foreignDataDecl && foreignDataDecl.value.includes('foreign import data ForeignDataType :: Type'), 'getTopLevelDeclarations finds ForeignDataType (DeclKindSignature).', 'AST - getTopLevelDeclarations - ForeignDataType');
447
+
448
+ const foreignFuncDecl = declsResult.find(d => d.name === 'foreignFunc' && d.type === 'DeclForeign');
449
+ assert(foreignFuncDecl && foreignFuncDecl.value.includes('foreign import foreignFunc :: Int -> String'), 'getTopLevelDeclarations finds foreignFunc (DeclForeign).', 'AST - getTopLevelDeclarations - foreignFunc');
450
+
451
+ const roleDecl = declsResult.find(d => d.name === 'MyData' && d.type === 'DeclRole');
452
+ assert(roleDecl && roleDecl.value.includes('type role MyData representational'), 'getTopLevelDeclarations finds MyData role (DeclRole).', 'AST - getTopLevelDeclarations - MyData Role');
453
+
454
+ const fixityDecl = declsResult.find(d => d.name === '/\\' && d.type === 'DeclFixity');
455
+ assert(fixityDecl && fixityDecl.value.includes('infix 6 type Tuple as /\\'), 'getTopLevelDeclarations finds Tuple infix (DeclFixity).', 'AST - getTopLevelDeclarations - Tuple Infix');
456
+
457
+ // Test filtering
458
+ const filteredDeclsName = await callMcpTool('getTopLevelDeclarations', { code: getTopLevelDeclarationsTestCode, filters: { name: "myFunction" } });
459
+ assert(filteredDeclsName && filteredDeclsName.length === 2 && filteredDeclsName.every(d => d.name === "myFunction"), 'getTopLevelDeclarations filters by name (expects 2: Sig and Val).', 'AST - getTopLevelDeclarations - Filter Name');
460
+ const hasSig = filteredDeclsName.some(d => d.type === "DeclSignature");
461
+ const hasVal = filteredDeclsName.some(d => d.type === "DeclValue");
462
+ assert(hasSig && hasVal, 'Filtered myFunction results include both DeclSignature and DeclValue.', 'AST - getTopLevelDeclarations - Filter Name Types');
463
+
464
+ const filteredDeclsType = await callMcpTool('getTopLevelDeclarations', { code: getTopLevelDeclarationsTestCode, filters: { type: "DeclData" } });
465
+ assert(filteredDeclsType && filteredDeclsType.length === 1 && filteredDeclsType[0].type === "DeclData", 'getTopLevelDeclarations filters by type.', 'AST - getTopLevelDeclarations - Filter Type');
466
+
467
+ const filteredDeclsValue = await callMcpTool('getTopLevelDeclarations', { code: getTopLevelDeclarationsTestCode, filters: { value: "Effect Unit" } });
468
+ assert(filteredDeclsValue && filteredDeclsValue.length === 1 && filteredDeclsValue[0].name === "anotherFunction", 'getTopLevelDeclarations filters by value.', 'AST - getTopLevelDeclarations - Filter Value');
469
+ }
470
+
471
+ // 3. getFunctionNames
472
+ testResult = await callMcpTool('getFunctionNames', { code: functionsCode });
473
+ assertDeepEqual(testResult, ["myFunction", "anotherFunction", "main"], 'getFunctionNames finds all functions.', 'AST - getFunctionNames');
474
+
475
+ // 16. getWhereBindings
476
+ testResult = await callMcpTool('getWhereBindings', { code: whereBindingsCode });
477
+ const expectedWhereBlock = `where
478
+ intermediate = x + 1
479
+ result = intermediate * 2`;
480
+ // The tool returns an array of where block texts.
481
+ assert(Array.isArray(testResult) && testResult.length === 1 && testResult[0].replace(/\s+/g, ' ').trim() === expectedWhereBlock.replace(/\s+/g, ' ').trim(),
482
+ `getWhereBindings returns the full where block text. Expected: "${expectedWhereBlock.replace(/\s+/g, ' ')}", Got: "${testResult && testResult[0] ? testResult[0].replace(/\s+/g, ' ') : 'undefined'}"`,
483
+ 'AST - getWhereBindings');
484
+
485
+ // Test deprecated query_purescript_ast
486
+ const mainPursContentForOldTest = `module Main where main = pure unit`;
487
+ const astTestModuleNameOld = await callMcpTool('query_purescript_ast', {
488
+ purescript_code: mainPursContentForOldTest,
489
+ tree_sitter_query: "(purescript name: (qualified_module (module) @module.name))"
490
+ });
491
+ assert(astTestModuleNameOld.results && astTestModuleNameOld.results[0]?.text === 'Main', 'Deprecated AST query for module name.', 'AST Query (Deprecated) - Module Name');
492
+
493
+ }
494
+
495
+
496
+ async function runTests() {
497
+ console.log(chalk.cyan.bold("Starting MCP Server Automated Tests (JSON-RPC over Stdio)...\n"));
498
+
499
+ await startMcpServer();
500
+ await initializeMcpServer();
501
+
502
+ // Test 1: echo
503
+ const echoResult = await callMcpTool('echo', { message: 'Hello Test' });
504
+ assert(echoResult && typeof echoResult === 'string' && echoResult.trim() === 'Echo: Hello Test',
505
+ `Echo tool responds correctly. Got: "${echoResult}" (type: ${typeof echoResult})`,
506
+ 'Echo');
507
+
508
+ // Test 2: get_server_status
509
+ const serverStatusResult = await callMcpTool('get_server_status');
510
+ assert(serverStatusResult && serverStatusResult.status === 'running' && serverStatusResult.purescript_tools_mcp_version, 'get_server_status reports running and version.', 'Server Status');
511
+ if (serverStatusResult && serverStatusResult.purs_ide_server_status) {
512
+ assert(serverStatusResult.purs_ide_server_status.status === 'not_started' || serverStatusResult.purs_ide_server_status.status === 'stopped', 'get_server_status reports purs_ide_server initially not started or stopped.', 'Server Status - Purescript IDE');
513
+ }
514
+
515
+
516
+ // Run AST tool tests
517
+ await runAstToolTests();
518
+
519
+ // Test: start_purs_ide_server
520
+ console.log(chalk.yellow(`\nINFO: For 'start_purs_ide_server' test, ensure '${TEST_PROJECT_PATH}' is compiled (run 'spago build' in it).`));
521
+ const startIdeResult = await callMcpTool('start_purs_ide_server', {
522
+ project_path: TEST_PROJECT_PATH,
523
+ log_level: "debug"
524
+ });
525
+ assert(startIdeResult && startIdeResult.status_message && typeof startIdeResult.port === 'number', 'start_purs_ide_server reports success and returns a port number.', 'Start purs ide');
526
+ if (startIdeResult && startIdeResult.initial_load_result) {
527
+ logTest(`Initial load result from purs ide: ${JSON.stringify(startIdeResult.initial_load_result)}`, 'info');
528
+ assert(startIdeResult.initial_load_result.resultType === 'success', 'purs ide initial load successful.', 'Start purs ide - Initial Load');
529
+ } else if (startIdeResult && startIdeResult.status_message && startIdeResult.status_message.includes("initial load command failed")) {
530
+ logTest("Warning: purs ide server started but initial load failed. This might be okay if project wasn't compiled. Check logs.", 'warn');
531
+ }
532
+
533
+ let pursIdeReadyForQuery = startIdeResult && startIdeResult.status_message && (startIdeResult.initial_load_result?.resultType === 'success' || startIdeResult.status_message.includes("initial load command failed"));
534
+
535
+ if (pursIdeReadyForQuery && startIdeResult.initial_load_result?.resultType !== 'success') {
536
+ logTest("Skipping some purs ide query tests as initial load was not fully successful. Compile the test project.", 'warn');
537
+ }
538
+
539
+ if (pursIdeReadyForQuery && startIdeResult.initial_load_result?.resultType === 'success') {
540
+ const completeTest = await callMcpTool('query_purs_ide', {
541
+ purs_ide_command: "complete",
542
+ purs_ide_params: { filters: [{"filter": "exact", "params": {"search": "log"}}], currentModule: "Main", options: {maxResults: 5} }
543
+ });
544
+ assert(completeTest && completeTest.resultType === 'success' && completeTest.result.some(r => r.identifier === 'log' && r.module === 'Effect.Console'), 'query_purs_ide for "complete log".', 'Query purs ide - Complete');
545
+
546
+ const typeTestOld = await callMcpTool('query_purs_ide', {
547
+ purs_ide_command: "type",
548
+ purs_ide_params: { search: "main", currentModule: "Main" }
549
+ });
550
+ assert(typeTestOld && typeTestOld.resultType === 'success' && typeTestOld.result.some(r => r.identifier === 'main' && r.type.includes('Effect Unit')), 'query_purs_ide for "type main".', 'Query purs ide - Type (Old)');
551
+
552
+ const usagesTestOld = await callMcpTool('query_purs_ide', {
553
+ purs_ide_command: "usages",
554
+ purs_ide_params: { module: "Effect.Console", namespace: "value", identifier: "log" }
555
+ });
556
+ assert(usagesTestOld && usagesTestOld.resultType === 'success' && usagesTestOld.result.length >= 2, 'query_purs_ide for "usages log".', 'Query purs ide - Usages (Old)');
557
+
558
+ const depGraphTest = await callMcpTool('generate_dependency_graph', {
559
+ target_modules: ["Main", "Utils", "Effect.Console"]
560
+ });
561
+ assert(depGraphTest && depGraphTest.graph_nodes && depGraphTest.graph_nodes.some(n => n.id === "Main.main"), 'generate_dependency_graph for Main module.', 'Dependency Graph - Main Module Presence');
562
+
563
+ // Run tests for dedicated purs ide tools
564
+ await runDedicatedPursIdeToolTests();
565
+
566
+ } else {
567
+ logTest("Skipping purs ide query, dependency graph, and dedicated purs ide tool tests as purs ide server did not start/load project successfully.", 'warn');
568
+ testsFailed += 4; // Mark these dependent tests as failed (query_purs_ide tests)
569
+ testsFailed += 8; // Mark dedicated purs ide tool tests as failed (7 existing + 1 new for pursIdeQuit)
570
+ }
571
+
572
+ const stopIdeResult = await callMcpTool('stop_purs_ide_server', {});
573
+ const stopMessage = stopIdeResult ? stopIdeResult.status_message : "";
574
+ assert(stopIdeResult && (stopMessage === "purs ide server stopped." || stopMessage === "No purs ide server was running."),
575
+ `stop_purs_ide_server reports appropriate status. Got: "${stopMessage}"`,
576
+ 'Stop purs ide');
577
+
578
+
579
+ console.log(chalk.cyan.bold("\n--- Test Summary ---"));
580
+ console.log(chalk.green(`Passed: ${testsPassed}`));
581
+ console.log(chalk.red(`Failed: ${testsFailed}`));
582
+
583
+ if (mcpServerProcess) {
584
+ logTest('Stopping MCP server process...', 'info');
585
+ mcpServerProcess.kill();
586
+ }
587
+
588
+ if (testsFailed > 0) {
589
+ console.error(chalk.red.bold("\nSome tests failed."));
590
+ process.exit(1);
591
+ } else {
592
+ console.log(chalk.green.bold("\nAll tests passed!"));
593
+ process.exit(0);
594
+ }
595
+ }
596
+
597
+ runTests().catch(err => {
598
+ console.error(chalk.redBright.bold("Unhandled error during test execution:"), err);
599
+ if (mcpServerProcess) {
600
+ mcpServerProcess.kill();
601
+ }
602
+ process.exit(1);
603
+ });
604
+
605
+ async function runDedicatedPursIdeToolTests() {
606
+ logTest("--- Running Dedicated Purs IDE Tool Tests ---", "info");
607
+
608
+ // Test: pursIdeLoad (all modules - already done by start_purs_ide_server, but test explicit call)
609
+ const loadAllResult = await callMcpTool('pursIdeLoad', {});
610
+ assert(loadAllResult && loadAllResult.resultType === 'success', 'pursIdeLoad (all modules) reports success.', 'PursIDE - Load All');
611
+
612
+ // Test: pursIdeLoad (specific module)
613
+ const loadSpecificResult = await callMcpTool('pursIdeLoad', { modules: ["Main"] });
614
+ assert(loadSpecificResult && loadSpecificResult.resultType === 'success', 'pursIdeLoad (specific module "Main") reports success.', 'PursIDE - Load Specific');
615
+
616
+ // Test: pursIdeCwd
617
+ const cwdResult = await callMcpTool('pursIdeCwd');
618
+ assert(cwdResult && cwdResult.result === TEST_PROJECT_PATH, `pursIdeCwd returns correct path. Expected: ${TEST_PROJECT_PATH}, Got: ${cwdResult.result}`, 'PursIDE - Cwd');
619
+
620
+ // Test: pursIdeType
621
+ const typeResult = await callMcpTool('pursIdeType', { search: "main", currentModule: "Main" });
622
+ assert(typeResult && typeResult.resultType === 'success' && typeResult.result.some(r => r.identifier === 'main' && r.type.includes('Effect Unit')), 'pursIdeType for "main" in "Main".', 'PursIDE - Type');
623
+
624
+ const typeWithFilterResult = await callMcpTool('pursIdeType', { search: "log", filters: [{ "filter": "exact", "params": {"search": "log", "module": ["Effect.Console"]}}] });
625
+ assert(typeWithFilterResult && typeWithFilterResult.resultType === 'success' && typeWithFilterResult.result.some(r => r.identifier === 'log' && r.module === 'Effect.Console'), 'pursIdeType for "log" with module filter.', 'PursIDE - Type with Filter');
626
+
627
+ // Test: pursIdeUsages
628
+ const usagesResult = await callMcpTool('pursIdeUsages', { module: "Effect.Console", namespace: "value", identifier: "log" });
629
+ assert(usagesResult && usagesResult.resultType === 'success' && usagesResult.result.length >= 2, 'pursIdeUsages for "log" in "Effect.Console".', 'PursIDE - Usages');
630
+
631
+ // Test: pursIdeList (availableModules)
632
+ const listModulesResult = await callMcpTool('pursIdeList', { listType: "availableModules" });
633
+ assert(listModulesResult && listModulesResult.resultType === 'success' && Array.isArray(listModulesResult.result) && listModulesResult.result.includes("Main"), 'pursIdeList (availableModules) lists "Main".', 'PursIDE - List Available Modules');
634
+
635
+ // Test: pursIdeList (import)
636
+ const listImportResult = await callMcpTool('pursIdeList', { listType: "import", file: path.join(TEST_PROJECT_PATH, "src/Main.purs") });
637
+ assert(listImportResult && listImportResult.resultType === 'success' &&
638
+ listImportResult.result && Array.isArray(listImportResult.result.imports) &&
639
+ listImportResult.result.imports.some(imp => imp.module === "Effect.Console"),
640
+ 'pursIdeList (import for Main.purs) lists "Effect.Console".', 'PursIDE - List Imports');
641
+
642
+ // Test: pursIdeRebuild
643
+ // For a simple rebuild test, we'll use the existing Utils.purs.
644
+ // A more robust test might involve modifying the file, but for now, just check if rebuild runs.
645
+ const utilsFilePath = path.join(TEST_PROJECT_PATH, 'src/Utils.purs');
646
+ const rebuildResult = await callMcpTool('pursIdeRebuild', { file: utilsFilePath });
647
+ assert(rebuildResult && rebuildResult.resultType === 'success', `pursIdeRebuild for "${utilsFilePath}" reports success.`, 'PursIDE - Rebuild');
648
+ if (rebuildResult && rebuildResult.resultType === 'success' && rebuildResult.result && rebuildResult.result.length > 0) {
649
+ const firstRebuildEntry = rebuildResult.result[0];
650
+ assert(firstRebuildEntry.file === utilsFilePath && firstRebuildEntry.status === 'rebuilt', 'pursIdeRebuild result entry indicates rebuilt file.', 'PursIDE - Rebuild Status');
651
+ } else if (rebuildResult && rebuildResult.resultType === 'success' && (!rebuildResult.result || rebuildResult.result.length === 0)) {
652
+ logTest(`pursIdeRebuild for "${utilsFilePath}" succeeded but returned empty result array. This might mean no changes were detected.`, 'warn');
653
+ assert(true, `pursIdeRebuild for "${utilsFilePath}" succeeded (empty result).`, 'PursIDE - Rebuild');
654
+ }
655
+
656
+
657
+ // Test: pursIdeReset
658
+ const resetResult = await callMcpTool('pursIdeReset');
659
+ assert(resetResult && resetResult.resultType === 'success', 'pursIdeReset reports success.', 'PursIDE - Reset');
660
+ // After reset, a type query might fail or return empty if modules are truly cleared.
661
+ // However, the server might auto-reload. Let's re-load to be sure for subsequent tests if any.
662
+ await callMcpTool('pursIdeLoad', {}); // Reload all for safety
663
+
664
+ // Test: pursIdeQuit
665
+ // This should cause the purs ide server to stop.
666
+ // The subsequent call to 'stop_purs_ide_server' in the main runTests function
667
+ // should then confirm it's stopped or handle it gracefully.
668
+ logTest("Attempting to quit purs ide server via pursIdeQuit tool...", "info");
669
+ const quitResult = await callMcpTool('pursIdeQuit');
670
+ assert(quitResult && (quitResult.resultType === 'success' || quitResult.message === 'purs ide server quit successfully.'), 'pursIdeQuit reports success.', 'PursIDE - Quit');
671
+
672
+ // Verify server status after quit
673
+ const serverStatusAfterQuit = await callMcpTool('get_server_status');
674
+ if (serverStatusAfterQuit && serverStatusAfterQuit.purs_ide_server_status) {
675
+ assert(serverStatusAfterQuit.purs_ide_server_status.status === 'stopped' || serverStatusAfterQuit.purs_ide_server_status.status === 'not_running', 'get_server_status reports purs_ide_server stopped after pursIdeQuit.', 'Server Status - Purescript IDE After Quit');
676
+ }
677
+ }