Skip to main content
Glama

PureScript MCP Server

by avi892nash
index.js63.9 kB
const { Parser, Language, Query } = require('web-tree-sitter'); const path = require('path'); const { spawn } = require('child_process'); const net = require('net'); const chalk = require('chalk'); const fs = require('fs').promises; const readline = require('readline'); // --- Log File --- const LOG_FILE_PATH = path.join(__dirname, 'purescript-mcp-server.log'); // --- Tree-sitter PureScript Parser State --- let PureScriptLanguage; let purescriptTsParser; let treeSitterInitialized = false; // --- purs ide Server State --- let pursIdeProcess = null; let pursIdeServerPort = null; let pursIdeProjectPath = null; let pursIdeIsReady = false; let pursIdeLogBuffer = []; const MAX_IDE_LOG_BUFFER = 200; // --- Constants for Dependency Graph --- const MAX_RESULTS_COMPLETIONS_FOR_GRAPH = 10000; const ENCLOSING_DECL_QUERY_SOURCE = `(function name: (_) @name) @decl ;; Covers top-level bindings and functions`; const MODULE_NAME_QUERY_SOURCE = "(purescript name: (qualified_module) @qmodule.name_node)"; let ENCLOSING_DECL_TS_QUERY; let MODULE_NAME_TS_QUERY; // --- Server Info and Capabilities (MCP Standard) --- const SERVER_INFO = { name: 'purescript-tools-mcp', version: '1.1.0', // Updated version for stdio protocol change description: 'Provides tools for PureScript development tasks via stdio MCP.' }; // Fix: Update SERVER_CAPABILITIES declaration const SERVER_CAPABILITIES = { resources: {}, // Empty if no resources tools: {}, // Tools are listed by tools/list, not in capabilities directly for full definitions resourceTemplates: {} // Empty if no resource templates }; // --- Logging --- function logToStderr(message, level = 'info') { const timestamp = new Date().toISOString(); const plainMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; // Only write to log file to avoid stdio conflicts fs.appendFile(LOG_FILE_PATH, plainMessage + '\n') .catch(err => { // Silently fail if file logging fails to avoid recursive stderr issues // Alternative: write to a different error log file if needed }); } function logPursIdeOutput(data, type = 'stdout') { const message = data.toString().trim(); const logType = type === 'stderr' ? 'error' : 'info'; logToStderr(`[purs ide ${type}]: ${message}`, logType); pursIdeLogBuffer.push(`[${type}] ${message}`); if (pursIdeLogBuffer.length > MAX_IDE_LOG_BUFFER) pursIdeLogBuffer.shift(); } // --- Initialization --- async function initializeTreeSitterParser() { try { await Parser.init(); const wasmPath = path.join(__dirname, 'tree-sitter-purescript.wasm'); PureScriptLanguage = await Language.load(wasmPath); purescriptTsParser = new Parser(); purescriptTsParser.setLanguage(PureScriptLanguage); ENCLOSING_DECL_TS_QUERY = new Query(PureScriptLanguage, ENCLOSING_DECL_QUERY_SOURCE); MODULE_NAME_TS_QUERY = new Query(PureScriptLanguage, MODULE_NAME_QUERY_SOURCE); treeSitterInitialized = true; logToStderr("Tree-sitter PureScript grammar and parser initialized successfully.", "info"); } catch (error) { logToStderr(`Failed to load Tree-sitter PureScript grammar: ${error.message}`, "error"); } } // --- Helper Functions --- function getNamespaceForDeclaration(declarationType) { /* ... */ } function getDeclarationId(decl) { /* ... */ } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Helper to find an available random port function findAvailablePort(startPort = 4242, endPort = 65535) { return new Promise((resolve, reject) => { const randomPort = Math.floor(Math.random() * (endPort - startPort + 1)) + startPort; const server = net.createServer(); server.listen(randomPort, (err) => { if (err) { // Port is busy, try another random port server.close(); if (randomPort === endPort) { reject(new Error(`No available ports found in range ${startPort}-${endPort}`)); } else { // Try a different random port resolve(findAvailablePort(startPort, endPort)); } } else { const port = server.address().port; server.close(() => { resolve(port); }); } }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { server.close(); // Try a different random port resolve(findAvailablePort(startPort, endPort)); } else { reject(err); } }); }); } // Helper to get code from input args (filePath or code string) async function getCodeFromInput(args, isModuleOriented = true) { if (isModuleOriented) { const hasFilePath = args && typeof args.filePath === 'string'; const hasCode = args && typeof args.code === 'string'; if ((hasFilePath && hasCode) || (!hasFilePath && !hasCode)) { throw new Error("Invalid input: Exactly one of 'filePath' or 'code' must be provided for module-oriented tools."); } if (hasFilePath) { if (!path.isAbsolute(args.filePath)) { throw new Error(`Invalid filePath: '${args.filePath}' is not an absolute path. Only absolute paths are supported.`); } try { return await fs.readFile(args.filePath, 'utf-8'); } catch (e) { throw new Error(`Failed to read file at ${args.filePath}: ${e.message}`); } } return args.code; } else { // Snippet-oriented if (!args || typeof args.code !== 'string') { throw new Error("Invalid input: 'code' (string) is required for snippet-oriented tools."); } return args.code; } } // (Full implementations for getNamespaceForDeclaration and getDeclarationId are kept from previous version) getNamespaceForDeclaration = function(declarationType) { switch (declarationType) { case "value": case "valueoperator": case "dataconstructor": return "value"; case "type": case "typeoperator": case "synonym": case "typeclass": return "type"; case "kind": return "kind"; default: return null; } }; getDeclarationId = function(decl) { if (!decl || !decl.module || !decl.identifier) return `unknown.${Date.now()}.${Math.random()}`; return `${decl.module}.${decl.identifier}`; }; // --- `purs ide` Communication & Management (largely unchanged) --- function sendCommandToPursIde(commandPayload) { return new Promise((resolve, reject) => { if (!pursIdeProcess || !pursIdeIsReady || !pursIdeServerPort) { return reject(new Error("purs ide server is not running or not ready.")); } const client = new net.Socket(); let responseData = ''; client.connect(pursIdeServerPort, '127.0.0.1', () => { logToStderr(`[MCP Client->purs ide]: Sending command: ${JSON.stringify(commandPayload).substring(0,100)}...`, 'debug'); client.write(JSON.stringify(commandPayload) + '\n'); }); client.on('data', (data) => { responseData += data.toString(); if (responseData.includes('\n')) { const completeResponses = responseData.split('\n').filter(Boolean); responseData = ''; if (completeResponses.length > 0) { try { resolve(JSON.parse(completeResponses[0].trim())); } catch (e) { reject(new Error(`Failed to parse JSON response from purs ide: ${e.message}. Raw: ${completeResponses[0]}`)); } } client.end(); } }); client.on('end', () => { if (responseData.trim()) { try { resolve(JSON.parse(responseData.trim())); } catch (e) { reject(new Error(`Failed to parse JSON response from purs ide on end: ${e.message}. Raw: ${responseData}`));} } }); client.on('close', () => { logToStderr(`[MCP Client->purs ide]: Connection closed.`, 'debug'); }); client.on('error', (err) => reject(new Error(`TCP connection error with purs ide server: ${err.message}`))); }); } // --- Internal Tool Handlers (adapted from previous version) --- // These functions now expect 'args' to be the 'params' or 'arguments' object from the 'tools/call' request. // Updated to return MCP standard response format { content: [{type: "text", text: ...}] } async function internalHandleGetServerStatus() { const statusResponse = { status: 'running', // Overall server status purescript_tools_mcp_version: SERVER_INFO.version, treeSitterInitialized, purs_ide_server_status: { // Renamed for clarity and to match test assertion expectation status: pursIdeProcess ? (pursIdeIsReady ? 'ready' : 'starting') : (pursIdeServerPort ? 'stopped' : 'not_started'), running: !!pursIdeProcess, // Keep this for direct boolean check if needed ready: pursIdeIsReady, port: pursIdeServerPort, projectPath: pursIdeProjectPath, recentLogs: pursIdeLogBuffer.slice(-10) } }; return { content: [{ type: "text", text: JSON.stringify(statusResponse, null, 2) }] }; } async function internalHandleEcho(args) { if (!args || typeof args.message !== 'string') { throw new Error("Invalid input. 'message' (string) is required."); } return { content: [{ type: "text", text: `Echo: ${args.message}` }] }; } async function internalHandleQueryPurescriptAst(args) { if (!treeSitterInitialized || !PureScriptLanguage || !purescriptTsParser) { throw new Error("Tree-sitter PureScript grammar/parser not loaded."); } if (!args || typeof args.purescript_code !== 'string' || typeof args.tree_sitter_query !== 'string') { throw new Error("Invalid input: 'purescript_code' and 'tree_sitter_query' (strings) are required."); } const tree = purescriptTsParser.parse(args.purescript_code); const query = new Query(PureScriptLanguage, args.tree_sitter_query); const captures = query.captures(tree.rootNode); const results = captures.map(capture => ({ name: capture.name, text: capture.node.text })); return { content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }] }; } async function internalHandleStartPursIdeServer(args) { if (pursIdeProcess) { logToStderr("Stopping existing purs ide server before starting a new one.", "warn"); pursIdeProcess.kill(); pursIdeProcess = null; pursIdeIsReady = false; } if (!args.project_path || typeof args.project_path !== 'string') { throw new Error("Invalid input: 'project_path' (string) is required for start_purs_ide_server."); } // Always use a random available port try { pursIdeServerPort = await findAvailablePort(); logToStderr(`Using random available port: ${pursIdeServerPort}`, "info"); } catch (portError) { throw new Error(`Failed to find available port: ${portError.message}`); } if (!path.isAbsolute(args.project_path)) { throw new Error(`Invalid project_path: '${args.project_path}' is not an absolute path. Only absolute paths are supported.`); } const resolvedProjectPath = args.project_path; // Validate that the resolved path exists and is a directory try { const stats = await fs.stat(resolvedProjectPath); if (!stats.isDirectory()) { throw new Error(`Project path '${resolvedProjectPath}' is not a directory.`); } logToStderr(`Validated project directory exists: "${resolvedProjectPath}"`, 'debug'); } catch (e) { throw new Error(`Invalid project_path: ${resolvedProjectPath}. Error: ${e.message}`); } pursIdeProjectPath = resolvedProjectPath; const outputDir = args.output_directory || "output/"; const sourceGlobs = args.source_globs || ["src/**/*.purs", ".spago/*/*/src/**/*.purs", "test/**/*.purs"]; const logLevel = args.log_level || "none"; pursIdeLogBuffer = []; const cmdArgs = ['ide', 'server', '--port', pursIdeServerPort.toString(), '--output-directory', outputDir, '--log-level', logLevel, ...sourceGlobs]; const fullCommand = `npx purs ${cmdArgs.join(' ')}`; logToStderr(`Spawning '${fullCommand}' in CWD: ${pursIdeProjectPath}`, "info"); return new Promise((resolve, reject) => { pursIdeProcess = spawn('npx', ['purs', ...cmdArgs], { cwd: pursIdeProjectPath, shell: false, env: process.env }); pursIdeIsReady = false; pursIdeProcess.stdout.on('data', (data) => logPursIdeOutput(data, 'stdout')); pursIdeProcess.stderr.on('data', (data) => logPursIdeOutput(data, 'stderr')); pursIdeProcess.on('error', (err) => { const errorMsg = `Failed to start purs ide server process: ${err.message}`; logPursIdeOutput(errorMsg, 'error'); pursIdeProcess = null; reject(new Error(errorMsg)); }); pursIdeProcess.on('close', (code) => { const codeMessage = `purs ide server process exited with code ${code}`; logPursIdeOutput(codeMessage, code === 0 ? 'info' : 'error'); if (pursIdeProcess) { pursIdeProcess = null; pursIdeIsReady = false; } if (code !== 0) { reject(new Error(`Server failed to start (exit code ${code})`)); } }); // Check if server started successfully after a short delay setTimeout(async () => { if (!pursIdeProcess) { return; // Process already exited } try { logToStderr("Attempting initial 'load' command to purs ide server...", "info"); pursIdeIsReady = true; const loadResult = await sendCommandToPursIde({ command: "load", params: {} }); logToStderr("Initial 'load' command to purs ide server successful.", "info"); const result = { status_message: "purs ide server started and initial load attempted.", command_executed: fullCommand, port: pursIdeServerPort, project_path: pursIdeProjectPath, initial_load_result: loadResult, logs: pursIdeLogBuffer.slice(-20) }; resolve({ content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }); } catch (error) { pursIdeIsReady = false; logToStderr(`Error during initial 'load' to purs ide server: ${error.message}`, "error"); if(pursIdeProcess) { pursIdeProcess.kill(); pursIdeProcess = null; } reject(new Error(`purs ide server started but initial load command failed: ${error.message}`)); } }, 3000); }); } async function internalHandleStopPursIdeServer() { let message; if (pursIdeProcess) { pursIdeProcess.kill(); pursIdeProcess = null; pursIdeIsReady = false; logPursIdeOutput("purs ide server stopped by MCP.", "info"); message = "purs ide server stopped."; } else { message = "No purs ide server was running."; } return { content: [{ type: "text", text: JSON.stringify({ status_message: message }, null, 2) }] }; } async function internalHandleQueryPursIde(args) { if (!pursIdeProcess || !pursIdeIsReady) { throw new Error("purs ide server is not running or not ready. Please start it first."); } if (!args || typeof args.purs_ide_command !== 'string') { throw new Error("Invalid input. 'purs_ide_command' (string) is required."); } const result = await sendCommandToPursIde({ command: args.purs_ide_command, params: args.purs_ide_params || {} }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } async function internalHandleGenerateDependencyGraph(args) { if (!pursIdeProcess || !pursIdeIsReady) throw new Error("purs ide server is not running or not ready. Please start it first."); if (!treeSitterInitialized || !purescriptTsParser || !PureScriptLanguage) throw new Error("Tree-sitter parser for PureScript not initialized."); if (!args || !Array.isArray(args.target_modules) || args.target_modules.some(m => typeof m !== 'string')) { throw new Error("Invalid input: 'target_modules' (array of strings) is required."); } const { target_modules, max_concurrent_requests = 5 } = args; const graphNodesMap = new Map(); const graphNodesList = []; logToStderr(`[DepGraph]: Phase 1: Identifying all declarations in [${target_modules.join(', ')}]...`, "info"); for (const moduleName of target_modules) { try { const completeResponse = await sendCommandToPursIde({ command: "complete", params: { filters: [{ filter: "modules", params: { modules: [moduleName] } }], matcher: {}, options: { maxResults: MAX_RESULTS_COMPLETIONS_FOR_GRAPH, groupReexports: false } } }); if (completeResponse.resultType === "success" && Array.isArray(completeResponse.result)) { completeResponse.result.forEach(decl => { if (decl.definedAt && decl.definedAt.name) { const declId = getDeclarationId(decl); if (!graphNodesMap.has(declId)) { const node = { id: declId, module: decl.module, identifier: decl.identifier, type: decl.type, declarationType: decl.declarationType, definedAt: decl.definedAt, filePath: path.relative(pursIdeProjectPath || process.cwd(), decl.definedAt.name), usedBy: [] }; graphNodesMap.set(declId, node); graphNodesList.push(node); } } }); } else { logToStderr(`[DepGraph]: Could not get completions for module ${moduleName}: ${JSON.stringify(completeResponse.result)}`, "warn"); } } catch (error) { logToStderr(`[DepGraph]: Error fetching completions for module ${moduleName}: ${error.message}`, "error"); } } logToStderr(`[DepGraph]: Identified ${graphNodesList.length} declarations with source locations.`, "info"); logToStderr(`[DepGraph]: Phase 2: Identifying dependencies...`, "info"); const processUsageQueue = []; let activePromises = 0; let processedDeclarations = 0; for (const sourceDeclNode of graphNodesList) { const taskFn = async () => { activePromises++; try { const namespace = getNamespaceForDeclaration(sourceDeclNode.declarationType); if (!namespace) return; const usagesResponse = await sendCommandToPursIde({ command: "usages", params: { module: sourceDeclNode.module, identifier: sourceDeclNode.identifier, namespace: namespace } }); if (usagesResponse.resultType === "success" && Array.isArray(usagesResponse.result)) { const usagesByFile = {}; usagesResponse.result.forEach(usageLoc => { if (usageLoc && usageLoc.name && usageLoc.start && usageLoc.end) { if (!usagesByFile[usageLoc.name]) usagesByFile[usageLoc.name] = []; usagesByFile[usageLoc.name].push(usageLoc); } }); for (const absoluteFilePath in usagesByFile) { try { const resolvedFilePath = path.isAbsolute(absoluteFilePath) ? absoluteFilePath : path.resolve(pursIdeProjectPath, absoluteFilePath); const fileContent = await fs.readFile(resolvedFilePath, "utf-8"); const tree = purescriptTsParser.parse(fileContent); const relativeFilePath = path.relative(pursIdeProjectPath || process.cwd(), resolvedFilePath); usagesByFile[absoluteFilePath].forEach(usageLocation => { const usageStartPoint = { row: usageLocation.start[0] - 1, column: usageLocation.start[1] - 1 }; const usageEndPoint = { row: usageLocation.end[0] - 1, column: usageLocation.end[1] - 1 }; let bestMatchDeclNode = null; let bestMatchNameNodeText = null; let smallestSpanSize = Infinity; const matches = ENCLOSING_DECL_TS_QUERY.matches(tree.rootNode); for (const match of matches) { const declCapture = match.captures.find(c => c.name === 'decl'); const nameCapture = match.captures.find(c => c.name === 'name'); if (declCapture && nameCapture) { const declNode = declCapture.node; const declStartPoint = declNode.startPosition; const declEndPoint = declNode.endPosition; let withinBounds = usageStartPoint.row >= declStartPoint.row && usageEndPoint.row <= declEndPoint.row; if (withinBounds && usageStartPoint.row === declStartPoint.row && usageStartPoint.column < declStartPoint.column) withinBounds = false; if (withinBounds && usageEndPoint.row === declEndPoint.row && usageEndPoint.column > declEndPoint.column) withinBounds = false; if (withinBounds) { const spanSize = (declEndPoint.row - declStartPoint.row) * 10000 + (declEndPoint.column - (declStartPoint.row === declEndPoint.row ? declStartPoint.column : 0)); if (spanSize < smallestSpanSize) { smallestSpanSize = spanSize; bestMatchDeclNode = declNode; bestMatchNameNodeText = nameCapture.node.text; } } } } if (bestMatchDeclNode && bestMatchNameNodeText) { const callerIdentifierName = bestMatchNameNodeText; let callerModuleName = null; const moduleNameMatches = MODULE_NAME_TS_QUERY.matches(tree.rootNode); if (moduleNameMatches.length > 0 && moduleNameMatches[0].captures.length > 0) { const qmNodeCap = moduleNameMatches[0].captures.find(c => c.name === "qmodule.name_node"); if(qmNodeCap) callerModuleName = qmNodeCap.node.text.replace(/\\s+/g, ""); } if (callerIdentifierName && callerModuleName) { const callerId = `${callerModuleName}.${callerIdentifierName}`; const usageDetail = { file: relativeFilePath, moduleName: callerModuleName, declarationName: callerIdentifierName, startLine: usageLocation.start[0], startCol: usageLocation.start[1], endLine: usageLocation.end[0], endCol: usageLocation.end[1] }; const targetNode = graphNodesMap.get(sourceDeclNode.id); if(targetNode){ let existingCaller = targetNode.usedBy.find(u => u.from === callerId); if (!existingCaller) { existingCaller = { from: callerId, usagesAt: [] }; targetNode.usedBy.push(existingCaller); } 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)) { existingCaller.usagesAt.push(usageDetail); } } } else { logToStderr(`[DepGraph]: Could not extract caller module/id for usage in ${relativeFilePath} at L${usageLocation.start[0]}. CallerName: ${callerIdentifierName}, CallerModule: ${callerModuleName}`, "warn"); } } }); } catch (fileReadError) { logToStderr(`[DepGraph]: Error reading/parsing file ${absoluteFilePath}: ${fileReadError.message}`, "error"); } } } else if (usagesResponse.resultType === "error") { // logToStderr(`[DepGraph]: Could not get usages for ${sourceDeclNode.id}: ${JSON.stringify(usagesResponse.result)}`, "warn"); } } catch (error) { logToStderr(`[DepGraph]: Error processing usages for ${sourceDeclNode.id}: ${error.message}`, "error"); } finally { activePromises--; processedDeclarations++; if (processedDeclarations % 10 === 0 || processedDeclarations === graphNodesList.length) { logToStderr(`[DepGraph]: Processed ${processedDeclarations}/${graphNodesList.length} declarations for usages...`, "debug"); } } }; processUsageQueue.push(taskFn); while(processUsageQueue.length > 0 || activePromises > 0) { while(processUsageQueue.length > 0 && activePromises < max_concurrent_requests) { const taskToRun = processUsageQueue.shift(); if (taskToRun) taskToRun(); } await delay(50); } } logToStderr(`[DepGraph]: Dependency graph generation complete.`, "info"); const result = { graph_nodes: graphNodesList }; // graphNodesList is already the result return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } // --- Tool Definitions for 'tools/list' --- // Updated based on user feedback const TOOL_DEFINITIONS = [ { name: "get_server_status", 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.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { name: "echo", description: "Simple test tool that echoes back your input. Use to verify the MCP server is responding correctly.", inputSchema: { type: "object", properties: { message: { type: "string"}}, required: ["message"], additionalProperties: false }, }, { name: "query_purescript_ast", description: "[DEPRECATED] Parses PureScript code and executes a Tree-sitter query against its AST. Prefer specific AST query tools.", inputSchema: { type: "object", properties: { purescript_code: { type: "string" }, tree_sitter_query: { type: "string" }}, required: ["purescript_code", "tree_sitter_query"], additionalProperties: false }, }, // --- Phase 1: Core AST Query Tools --- // Module Information { name: "getModuleName", 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.", inputSchema: { type: "object", properties: { filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." }, code: { type: "string", description: "PureScript code string." } }, additionalProperties: false, description: "Exactly one of 'filePath' or 'code' must be provided." } }, { name: "getImports", 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.", inputSchema: { type: "object", properties: { filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." }, code: { type: "string", description: "PureScript code string." } }, additionalProperties: false, description: "Exactly one of 'filePath' or 'code' must be provided." } }, { name: "getTopLevelDeclarationNames", 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.", inputSchema: { type: "object", properties: { filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." }, code: { type: "string", description: "PureScript code string." } }, additionalProperties: false, description: "Exactly one of 'filePath' or 'code' must be provided." } }, // Function and Value Declarations { name: "getFunctionNames", description: "Extract only function names from PureScript code snippets. Focuses specifically on functions, ignoring data types and classes. Quick analysis for code understanding.", inputSchema: { type: "object", properties: { code: { type: "string", description: "PureScript code snippet." } }, required: ["code"], additionalProperties: false } }, // Expressions and Literals // Control Flow Analysis { name: "getWhereBindings", description: "Find 'where' clauses in PureScript functions. These contain local helper functions and variables. Useful for understanding function implementation details.", inputSchema: { type: "object", properties: { code: { type: "string", description: "PureScript code snippet." } }, required: ["code"], additionalProperties: false } }, { name: "getTopLevelDeclarations", 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.", inputSchema: { type: "object", properties: { filePath: { type: "string", description: "Absolute path to the PureScript file. Only absolute paths are supported." }, code: { type: "string", description: "PureScript code string." }, filters: { type: "object", properties: { name: { type: "string", description: "Regex to filter declarations by name." }, type: { type: "string", description: "Regex to filter declarations by their mapped type (e.g., DeclData, DeclValue)." }, value: { type: "string", description: "Regex to filter declarations by their full text value." } }, additionalProperties: false, description: "Optional filters to apply to the declarations." } }, additionalProperties: false, description: "Exactly one of 'filePath' or 'code' must be provided. Filters are optional." } }, // End of Phase 1 tools { name: "start_purs_ide_server", 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.", inputSchema: { type: "object", properties: { project_path: { type: "string", description: "Absolute path to the PureScript project directory. Only absolute paths are supported." }, output_directory: { type: "string", default: "output/" }, source_globs: { type: "array", items: { type: "string" }, default: ["src/**/*.purs", ".spago/*/*/src/**/*.purs", "test/**/*.purs"]}, log_level: { type: "string", enum: ["all", "debug", "perf", "none"], default: "none" } }, required: ["project_path"], additionalProperties: false }, }, { name: "stop_purs_ide_server", 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.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, { name: "query_purs_ide", 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.", inputSchema: { type: "object", properties: { purs_ide_command: { type: "string" }, purs_ide_params: { type: "object" }}, required: ["purs_ide_command"], additionalProperties: false }, }, { name: "generate_dependency_graph", 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.", inputSchema: { type: "object", properties: { target_modules: { type: "array", items: { type: "string" }, description: "Array of module names." }, max_concurrent_requests: { type: "integer", description: "Max concurrent 'usages' requests.", default: 5 } }, required: ["target_modules"], additionalProperties: false }, }, // --- purs ide direct command wrappers --- { name: "pursIdeLoad", 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.", inputSchema: { type: "object", properties: { modules: { type: "array", items: { type: "string" }, description: "Optional: specific modules to load. If omitted, attempts to load all compiled modules." } }, additionalProperties: false } }, { name: "pursIdeType", 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.", inputSchema: { type: "object", properties: { search: { type: "string", description: "Identifier name to search for." }, filters: { type: "array", items: { type: "object" }, description: "Optional: Array of Filter objects." }, currentModule: { type: "string", description: "Optional: Current module context." } }, required: ["search"], additionalProperties: false } }, { name: "pursIdeCwd", description: "Get the current working directory that the IDE server is using. PREREQUISITE: IDE server must be running. Useful for understanding the project context.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "pursIdeReset", 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.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "pursIdeQuit", 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.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, { name: "pursIdeRebuild", 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.", inputSchema: { type: "object", properties: { file: { type: "string", description: "Path to the module to rebuild, or 'data:' prefixed source code." }, actualFile: { type: "string", description: "Optional: Real path if 'file' is 'data:' or a temp file." }, codegen: { type: "array", items: { type: "string" }, description: "Optional: Codegen targets (e.g., 'js', 'corefn'). Defaults to ['js']." } }, required: ["file"], additionalProperties: false } }, { name: "pursIdeUsages", 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.", inputSchema: { type: "object", properties: { module: { type: "string", description: "Module where the identifier is defined." }, namespace: { type: "string", enum: ["value", "type", "kind"], description: "Namespace of the identifier." }, identifier: { type: "string", description: "The identifier to find usages for." } }, required: ["module", "namespace", "identifier"], additionalProperties: false } }, { name: "pursIdeList", 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.", inputSchema: { type: "object", properties: { listType: { type: "string", enum: ["availableModules", "import"], description: "Type of list to retrieve." }, file: { type: "string", description: "Path to the .purs file (required for 'import' listType)." } }, required: ["listType"], additionalProperties: false } } ]; // SERVER_CAPABILITIES.tools should remain an empty object {} // The client will use 'tools/list' to get the full definitions. // Map internal tool names to their handlers const INTERNAL_TOOL_HANDLERS = { "get_server_status": internalHandleGetServerStatus, "echo": internalHandleEcho, "query_purescript_ast": internalHandleQueryPurescriptAst, "start_purs_ide_server": internalHandleStartPursIdeServer, "stop_purs_ide_server": internalHandleStopPursIdeServer, "query_purs_ide": internalHandleQueryPursIde, "generate_dependency_graph": internalHandleGenerateDependencyGraph, // --- Phase 1: Core AST Query Tool Handlers (to be added) --- "getModuleName": async (args) => { if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized."); const code = await getCodeFromInput(args, true); const tree = purescriptTsParser.parse(code); // Corrected query to capture the full text of the qualified_module node const query = new Query(PureScriptLanguage, `(purescript name: (qualified_module) @module.qname)`); const captures = query.captures(tree.rootNode); if (captures.length > 0 && captures[0].name === 'module.qname') { // The text of the qualified_module node itself is the full module name return { content: [{ type: "text", text: JSON.stringify(captures[0].node.text.replace(/\s+/g, ''), null, 2) }] }; } return { content: [{ type: "text", text: JSON.stringify(null, null, 2) }] }; }, "getFunctionNames": async (args) => { if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized."); const code = await getCodeFromInput(args, false); const tree = purescriptTsParser.parse(code); const query = new Query(PureScriptLanguage, `(function name: (variable) @func.name)`); const captures = query.captures(tree.rootNode); const functionNames = captures.map(capture => capture.node.text); return { content: [{ type: "text", text: JSON.stringify(functionNames, null, 2) }] }; }, // Stubs for other Phase 1 handlers - to be implemented "getImports": async (args) => { if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized."); const code = await getCodeFromInput(args, true); const tree = purescriptTsParser.parse(code); const query = new Query(PureScriptLanguage, `(import module: (qualified_module) @import.path)`); const captures = query.captures(tree.rootNode); const imports = []; for (const capture of captures) { if (capture.name === 'import.path') { const moduleNodes = capture.node.children.filter(child => child.type === 'module'); if (moduleNodes.length > 0) { const fullPath = moduleNodes.map(n => n.text).join('.'); const moduleName = moduleNodes[0].text; const submoduleName = moduleNodes.length > 1 ? moduleNodes[1].text : undefined; imports.push({ module: moduleName, submodule: submoduleName, fullPath: fullPath }); } } } return { content: [{ type: "text", text: JSON.stringify(imports, null, 2) }] }; }, "getTopLevelDeclarationNames": async (args) => { if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized."); const code = await getCodeFromInput(args, true); // true for module-oriented const tree = purescriptTsParser.parse(code); const querySource = ` [ (function name: (variable) @name) (data name: (type) @name) (class_declaration (class_head (class_name (type) @name))) (type_alias name: (type) @name) (foreign_import name: (variable) @name) (signature name: (variable) @name) (class_instance (instance_name) @name) (kind_value_declaration name: (type) @name) ] `; const query = new Query(PureScriptLanguage, querySource); const captures = query.captures(tree.rootNode); const declarationNames = captures.map(capture => capture.node.text).filter(Boolean); // Deduplicate names const uniqueNames = [...new Set(declarationNames)]; return { content: [{ type: "text", text: JSON.stringify(uniqueNames, null, 2) }] }; }, "getIntegerLiterals": async (args) => { if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized."); const code = await getCodeFromInput(args, false); const tree = purescriptTsParser.parse(code); const integerLiterals = []; const query = new Query(PureScriptLanguage, `(integer) @integer.literal`); const captures = query.captures(tree.rootNode); captures.forEach(capture => { if (capture.name === 'integer.literal') { integerLiterals.push(parseInt(capture.node.text, 10)); } }); return { content: [{ type: "text", text: JSON.stringify(integerLiterals, null, 2) }] }; }, "getWhereBindings": async (args) => { if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized."); const code = await getCodeFromInput(args, false); // Snippet-oriented const tree = purescriptTsParser.parse(code); const whereClausesText = []; // Query for 'where' keyword followed by a 'declarations' block within a function or let binding const querySource = ` (function (where) @where_keyword (declarations) @declarations_block) `; // Also consider 'let' bindings with 'where' clauses, though less common for top-level 'where' // (let_binding (where) @where_keyword (declarations) @declarations_block) const query = new Query(PureScriptLanguage, querySource); const matches = query.matches(tree.rootNode); for (const match of matches) { const whereKeywordNode = match.captures.find(c => c.name === 'where_keyword')?.node; const declarationsNode = match.captures.find(c => c.name === 'declarations_block')?.node; if (whereKeywordNode && declarationsNode) { // Construct the text from "where" keyword to the end of the declarations block // This requires careful handling of start and end positions if they are not contiguous in the source text string // For simplicity, if they are siblings and in order, we can take text from start of 'where' to end of 'declarations' // A safer way is to combine their individual texts if they represent the full conceptual block const fullWhereClauseText = `${whereKeywordNode.text} ${declarationsNode.text}`; whereClausesText.push(fullWhereClauseText.trim()); } } // Deduplicate, as some complex structures might yield multiple partial matches const uniqueWhereClauses = [...new Set(whereClausesText)]; return { content: [{ type: "text", text: JSON.stringify(uniqueWhereClauses, null, 2) }] }; }, "getTopLevelDeclarations": async (args) => { if (!treeSitterInitialized) throw new Error("Tree-sitter not initialized."); const code = await getCodeFromInput(args, true); // true for module-oriented const tree = purescriptTsParser.parse(code); const querySource = ` [ (function name: (variable) @name.function) @DeclValue (data name: (type) @name.data_type) @DeclData (class_declaration (class_head (class_name (type) @name.class))) @DeclClass (type_alias name: (type) @name.type_alias) @DeclType (newtype name: (type) @name.newtype) @DeclNewtype (foreign_import name: (variable) @name.foreign) @DeclForeign (signature name: (variable) @name.signature) @DeclSignature (class_instance (instance_head (class_name) @name.instance_class (type_name)? @name.instance_type)) @DeclInstanceChain (kind_value_declaration name: (type) @name.kind_sig) @DeclKindSignature (derive_declaration) @DeclDerive (type_role_declaration (type) @name.role_type (type_role)+ @name.role_value) @DeclRole (operator_declaration (operator) @name.operator) @DeclFixity ] `; const query = new Query(PureScriptLanguage, querySource); const matches = query.matches(tree.rootNode); const rawDeclarations = []; for (const match of matches) { const mainCapture = match.captures.find(c => c.name.startsWith("Decl")); if (!mainCapture) continue; const declNode = mainCapture.node; const mappedType = mainCapture.name; const value = declNode.text; // This is the full text of the declaration node // Create a map of captures for efficient lookup // Store the full capture object {name, node} as node properties (like .text) are needed const allCapturesMap = new Map(match.captures.map(c => [c.name, c])); let finalName; // Prioritized list of capture names that directly provide the 'name' const singleNameCaptureKeys = [ "name.function", "name.data_type", "name.class", "name.type_alias", "name.newtype", "name.foreign", "name.signature", "name.kind_sig", "name.role_type", "name.operator" ]; let foundSingleName = false; for (const key of singleNameCaptureKeys) { if (allCapturesMap.has(key)) { finalName = allCapturesMap.get(key).node.text; foundSingleName = true; break; } } if (!foundSingleName) { if (allCapturesMap.has("name.instance_class")) { finalName = allCapturesMap.get("name.instance_class").node.text; if (allCapturesMap.has("name.instance_type")) { finalName += ` ${allCapturesMap.get("name.instance_type").node.text}`; } } else if (mappedType === "DeclDerive" || mappedType === "DeclFixity") { // declNode is mainCapture.node, which is already available const firstIdentNode = declNode.descendantsOfType("identifier")[0] || declNode.descendantsOfType("type")[0] || declNode.descendantsOfType("operator")[0]; finalName = firstIdentNode ? firstIdentNode.text : `complex_${mappedType.toLowerCase().replace('decl', '')}`; } else { finalName = "unknown"; // Default if no other specific name found } } rawDeclarations.push({ name: finalName, type: mappedType, value, treeSitterType: declNode.type }); // Removed node property } let declarations = rawDeclarations; // Use rawDeclarations directly without consolidation // Apply filters if provided if (args.filters) { const { name, type, value } = args.filters; if (name) { const nameRegex = new RegExp(name); declarations = declarations.filter(d => nameRegex.test(d.name)); } if (type) { const typeRegex = new RegExp(type); declarations = declarations.filter(d => typeRegex.test(d.type)); } if (value) { const valueRegex = new RegExp(value); declarations = declarations.filter(d => valueRegex.test(d.value)); } } return { content: [{ type: "text", text: JSON.stringify(declarations, null, 2) }] }; }, // --- New purs ide command wrapper handlers --- "pursIdeLoad": async (args) => { const params = args || {}; // If args is null/undefined, pass empty object for default load all const result = await sendCommandToPursIde({ command: "load", params }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, "pursIdeType": async (args) => { if (!args || typeof args.search !== 'string') { throw new Error("Invalid input: 'search' (string) is required for pursIdeType."); } const params = { search: args.search, filters: args.filters || [], // Default to empty filters array currentModule: args.currentModule }; const result = await sendCommandToPursIde({ command: "type", params }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, "pursIdeCwd": async () => { const result = await sendCommandToPursIde({ command: "cwd" }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, "pursIdeReset": async () => { const result = await sendCommandToPursIde({ command: "reset" }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, "pursIdeQuit": async () => { let quitMessage = "purs ide server quit command initiated."; let pursIdeResponded = false; if (pursIdeProcess && pursIdeIsReady) { logToStderr("[pursIdeQuit] Attempting to send 'quit' command to purs ide server.", "debug"); sendCommandToPursIde({ command: "quit" }) .then(res => { pursIdeResponded = true; logToStderr(`[pursIdeQuit] purs ide server responded to quit command: ${JSON.stringify(res)}`, 'debug'); }) .catch(err => { logToStderr(`[pursIdeQuit] Error/No response from purs ide server for quit command: ${err.message}`, 'warn'); }); // Wait a short period to allow purs ide server to shut down gracefully // or for the sendCommandToPursIde to potentially resolve/reject. await delay(250); // Increased slightly to 250ms } else { quitMessage = "No purs ide server was running or ready to send quit command to."; logToStderr("[pursIdeQuit] " + quitMessage, "info"); } // Ensure our managed process is stopped regardless of purs ide's response if (pursIdeProcess) { logToStderr("[pursIdeQuit] Ensuring managed purs ide process is stopped.", "debug"); pursIdeProcess.kill(); pursIdeProcess = null; pursIdeIsReady = false; logPursIdeOutput("Managed purs ide server process stopped via pursIdeQuit tool.", "info"); quitMessage += " Managed purs ide process has been stopped."; } else { if (!quitMessage.includes("No purs ide server was running")) { quitMessage += " No managed purs ide process was found running to stop."; } } if (pursIdeResponded) { quitMessage += " purs ide server acknowledged quit."; } else { quitMessage += " purs ide server may not have acknowledged quit before process termination."; } return { content: [{ type: "text", text: JSON.stringify({ status_message: quitMessage, resultType: "success" }, null, 2) }] }; }, "pursIdeRebuild": async (args) => { if (!args || typeof args.file !== 'string') { throw new Error("Invalid input: 'file' (string) is required for pursIdeRebuild."); } const params = { file: args.file, actualFile: args.actualFile, codegen: args.codegen // purs ide server defaults to js if undefined }; const result = await sendCommandToPursIde({ command: "rebuild", params }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, "pursIdeUsages": async (args) => { if (!args || typeof args.module !== 'string' || typeof args.namespace !== 'string' || typeof args.identifier !== 'string') { throw new Error("Invalid input: 'module', 'namespace', and 'identifier' (strings) are required for pursIdeUsages."); } const params = { module: args.module, namespace: args.namespace, identifier: args.identifier }; const result = await sendCommandToPursIde({ command: "usages", params }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, "pursIdeList": async (args) => { if (!args || typeof args.listType !== 'string') { throw new Error("Invalid input: 'listType' (string) is required for pursIdeList."); } const params = { type: args.listType }; if (args.listType === "import") { if (typeof args.file !== 'string') { throw new Error("'file' (string) is required when listType is 'import'."); } params.file = args.file; } const result = await sendCommandToPursIde({ command: "list", params }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } }; // --- MCP Stdio Protocol Handling --- function createSuccessResponse(id, result) { return { jsonrpc: '2.0', id, result }; } function createErrorResponse(id, code, message, data = undefined) { return { jsonrpc: '2.0', id, error: { code, message, data } }; } // Updated handleMcpRequest based on user feedback async function handleMcpRequest(request) { const { method, params, id } = request; try { if (method === 'initialize') { logToStderr(`Received initialize request from client (id: ${id}). Params: ${JSON.stringify(params)}`, 'info'); return createSuccessResponse(id, { protocolVersion: '2024-11-05', serverInfo: SERVER_INFO, capabilities: SERVER_CAPABILITIES // SERVER_CAPABILITIES.tools is now correctly an empty object }); } if (method === 'initialized' || method === 'notifications/initialized') { logToStderr(`Received initialized notification from client. Params: ${JSON.stringify(params)}`, 'info'); return null; } if (method === 'tools/list') { const toolsToExclude = ['query_purescript_ast', 'query_purs_ide']; // Keep query_purs_ide for now, for direct access if needed const filteredToolDefinitions = TOOL_DEFINITIONS.filter( tool => !toolsToExclude.includes(tool.name) ); return createSuccessResponse(id, { tools: filteredToolDefinitions }); } if (method === 'tools/call') { if (!params || typeof params.name !== 'string') { return createErrorResponse(id, -32602, "Invalid params: 'name' of tool is required for tools/call."); } const toolName = params.name; const toolArgs = params.arguments || {}; const handler = INTERNAL_TOOL_HANDLERS[toolName]; if (!handler) { return createErrorResponse(id, -32601, `Method not found (tool): ${toolName}`); } const result = await handler(toolArgs); // This now returns { content: [...] } return createSuccessResponse(id, result); // The entire { content: [...] } is the result for tools/call } if (method === 'resources/list') { return createSuccessResponse(id, { resources: [] }); } if (method === 'resources/templates/list') { return createSuccessResponse(id, { resourceTemplates: [] }); } if (method === 'resources/read') { return createErrorResponse(id, -32601, "No resources available to read"); } return createErrorResponse(id, -32601, `Method not found: ${method}`); } catch (error) { logToStderr(`Error handling MCP request (method: ${method}, id: ${id}): ${error.message}\n${error.stack}`, 'error'); return createErrorResponse(id, -32000, `Server error: ${error.message}`, { stack: error.stack }); } } // Updated rl.on('line') handler based on user feedback const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); rl.on('line', async (line) => { logToStderr(`Received line: ${line.substring(0, 200)}...`, 'debug'); let request; try { if (line.trim() === '') return; request = JSON.parse(line); } catch (e) { const errResp = createErrorResponse(null, -32700, 'Parse error', { details: e.message, receivedLine: line }); process.stdout.write(JSON.stringify(errResp) + '\n'); return; } if (typeof request.method !== 'string') { const errResp = createErrorResponse(request.id || null, -32600, 'Invalid Request: method must be a string.'); process.stdout.write(JSON.stringify(errResp) + '\n'); return; } const response = await handleMcpRequest(request); if (response !== null && response !== undefined) { process.stdout.write(JSON.stringify(response) + '\n'); logToStderr(`Sent response for id ${response.id}: ${JSON.stringify(response).substring(0,200)}...`, 'debug'); } else { logToStderr(`Handled notification (method: ${request.method}). No response sent.`, 'debug'); } }); rl.on('close', () => { logToStderr("Stdin closed. Exiting.", "info"); if (pursIdeProcess) { logToStderr("Stopping active purs ide server due to stdin close.", "warn"); pursIdeProcess.kill(); pursIdeProcess = null; } process.exit(0); }); // Graceful shutdown signals const shutdown = (signal) => { logToStderr(`Received ${signal}. MCP Server shutting down...`, 'info'); if (pursIdeProcess) { logToStderr("Stopping active purs ide server due to shutdown signal.", "warn"); pursIdeProcess.kill(); } process.exit(0); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); async function main() { logToStderr("PureScript MCP Stdio Server (JSON-RPC) starting...", "info"); await initializeTreeSitterParser(); logToStderr("Ready to process JSON-RPC commands from stdin.", "info"); // Server waits for the client to send the first 'initialize' request. } main();

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/avi892nash/purescript-mcp-tools'

If you have feedback or need assistance with the MCP directory API, please join our Discord server