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/LICENSE +21 -0
- package/README.md +160 -0
- package/index.js +1287 -0
- package/package.json +62 -0
- package/purescript-test-examples/packages.dhall +105 -0
- package/purescript-test-examples/spago.dhall +17 -0
- package/purescript-test-examples/src/Main.purs +13 -0
- package/purescript-test-examples/src/Utils.purs +23 -0
- package/purescript-test-examples/test/Main.purs +11 -0
- package/run_tests.js +677 -0
- package/tree-sitter-purescript.wasm +0 -0
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
|
+
}
|