MCP XMind Server

#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs/promises"; import path from "path"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import AdmZip from 'adm-zip'; // Command line argument parsing const args = process.argv.slice(2); if (args.length === 0) { console.error("Usage: mcp-server-xmind <allowed-directory> [additional-directories...]"); process.exit(1); } // Store allowed directories in normalized form const allowedDirectories = args.map(dir => path.normalize(path.resolve(dir)).toLowerCase() ); // Validate that all directories exist and are accessible await Promise.all(args.map(async (dir) => { try { const stats = await fs.stat(dir); if (!stats.isDirectory()) { console.error(`Error: ${dir} is not a directory`); process.exit(1); } } catch (error) { console.error(`Error accessing directory ${dir}:`, error); process.exit(1); } })); // Ajouter après la définition des allowedDirectories function isPathAllowed(filePath: string): boolean { const normalizedPath = path.normalize(path.resolve(filePath)).toLowerCase(); return allowedDirectories.some(dir => normalizedPath.startsWith(dir)); } // XMind Interfaces interface XMindNode { title: string; id?: string; children?: XMindNode[]; taskStatus?: 'done' | 'todo'; notes?: { content?: string; }; href?: string; labels?: string[]; sheetTitle?: string; callouts?: { title: string; }[]; relationships?: XMindRelationship[]; } interface XMindTopic { id: string; title: string; children?: { attached: XMindTopic[]; callout?: XMindTopic[]; }; extensions?: Array<{ provider: string; content: { status: 'done' | 'todo'; }; }>; notes?: { plain?: { content: string; }; realHTML?: { content: string; }; }; href?: string; labels?: string[]; } interface XMindRelationship { id: string; end1Id: string; end2Id: string; title?: string; } // Class XMindParser class XMindParser { private filePath: string; constructor(filePath: string) { const resolvedPath = path.resolve(filePath); if (!isPathAllowed(resolvedPath)) { throw new Error(`Access denied: ${filePath} is not in an allowed directory`); } this.filePath = resolvedPath; } public async parse(): Promise<XMindNode[]> { const contentJson = this.extractContentJson(); return this.parseContentJson(contentJson); } private extractContentJson(): string { try { const zip = new AdmZip(this.filePath); const contentEntry = zip.getEntry("content.json"); if (!contentEntry) { throw new Error("content.json not found in XMind file"); } return zip.readAsText(contentEntry); } catch (error) { throw new Error(`Failed to extract content.json: ${error}`); } } private parseContentJson(jsonContent: string): Promise<XMindNode[]> { try { const content = JSON.parse(jsonContent); const allNodes = content.map((sheet: { rootTopic: XMindTopic; title?: string; relationships?: XMindRelationship[]; }) => { const rootNode = this.processNode(sheet.rootTopic, sheet.title || "Untitled Map"); // Ajouter les relations au nœud racine if (sheet.relationships) { rootNode.relationships = sheet.relationships; } return rootNode; }); return Promise.resolve(allNodes); } catch (error) { return Promise.reject(`Failed to parse JSON content: ${error}`); } } private processNode(node: XMindTopic, sheetTitle?: string): XMindNode { const processedNode: XMindNode = { title: node.title, id: node.id, sheetTitle: sheetTitle || "Untitled Map" }; // Handle links, labels and callouts if (node.href) processedNode.href = node.href; if (node.labels) processedNode.labels = node.labels; if (node.children?.callout) { processedNode.callouts = node.children.callout.map(callout => ({ title: callout.title })); } // Handle notes and callouts if (node.notes?.plain?.content) { processedNode.notes = {}; // Process main note content if (node.notes?.plain?.content) { processedNode.notes.content = node.notes.plain.content; } } // Handle task status if (node.extensions) { const taskExtension = node.extensions.find((ext) => ext.provider === 'org.xmind.ui.task' && ext.content?.status ); if (taskExtension) { processedNode.taskStatus = taskExtension.content.status; } } // Process regular children if (node.children?.attached) { processedNode.children = node.children.attached.map(child => this.processNode(child, sheetTitle) ); } return processedNode; } } function getNodePath(node: XMindNode, parents: string[] = []): string { return parents.length > 0 ? `${parents.join(' > ')} > ${node.title}` : node.title; } // Schema definitions const ReadXMindArgsSchema = z.object({ path: z.string(), }); const ListXMindDirectoryArgsSchema = z.object({ directory: z.string().optional(), }); const ReadMultipleXMindArgsSchema = z.object({ paths: z.array(z.string()), }); const SearchXMindFilesSchema = z.object({ pattern: z.string(), directory: z.string().optional(), }); // Modifier le schéma pour refléter la nouvelle approche const ExtractNodeArgsSchema = z.object({ path: z.string(), searchQuery: z.string(), // Renommé de nodePath à searchQuery }); const ExtractNodeByIdArgsSchema = z.object({ path: z.string(), nodeId: z.string(), }); const SearchNodesArgsSchema = z.object({ path: z.string(), query: z.string(), searchIn: z.array(z.enum(['title', 'notes', 'labels', 'callouts', 'tasks'])).optional(), caseSensitive: z.boolean().optional(), taskStatus: z.enum(['todo', 'done']).optional(), // Ajout du filtre de statut de tâche }); interface MultipleXMindResult { filePath: string; content: XMindNode[]; error?: string; } async function readMultipleXMindFiles(paths: string[]): Promise<MultipleXMindResult[]> { const results: MultipleXMindResult[] = []; for (const filePath of paths) { if (!isPathAllowed(filePath)) { results.push({ filePath, content: [], error: `Access denied: ${filePath} is not in an allowed directory` }); continue; } try { const parser = new XMindParser(filePath); const content = await parser.parse(); results.push({ filePath, content }); } catch (error) { results.push({ filePath, content: [], error: error instanceof Error ? error.message : String(error) }); } } return results; } // Function to list XMind files async function listXMindFiles(directory?: string): Promise<string[]> { const files: string[] = []; const dirsToScan = directory ? [path.normalize(path.resolve(directory))] : allowedDirectories; for (const dir of dirsToScan) { // Check if directory is allowed const normalizedDir = dir.toLowerCase(); if (!allowedDirectories.some(allowed => normalizedDir.startsWith(allowed))) { continue; // Skip unauthorized directories } async function scanDirectory(currentDir: string) { try { const entries = await fs.readdir(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { await scanDirectory(fullPath); } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xmind')) { files.push(fullPath); } } } catch (error) { console.error(`Warning: Error scanning directory ${currentDir}:`, error); // Continue scanning other directories even if one fails } } await scanDirectory(dir); } return files; } // Add before server setup async function searchInXMindContent(filePath: string, searchText: string): Promise<boolean> { try { const zip = new AdmZip(filePath); const contentEntry = zip.getEntry("content.json"); if (!contentEntry) return false; const content = zip.readAsText(contentEntry); return content.toLowerCase().includes(searchText.toLowerCase()); } catch (error) { console.error(`Error reading XMind file ${filePath}:`, error); return false; } } // Modification de la fonction searchXMindFiles async function searchXMindFiles(pattern: string): Promise<string[]> { const matches: string[] = []; const contentMatches: string[] = []; const searchPattern = pattern.toLowerCase(); async function searchInDirectory(currentDir: string) { try { const entries = await fs.readdir(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { const normalizedPath = path.normalize(fullPath).toLowerCase(); if (allowedDirectories.some(allowed => normalizedPath.startsWith(allowed))) { await searchInDirectory(fullPath); } } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xmind')) { const searchableText = [ entry.name.toLowerCase(), path.basename(entry.name, '.xmind').toLowerCase(), fullPath.toLowerCase() ]; if (searchPattern === '' || searchableText.some(text => text.includes(searchPattern))) { matches.push(fullPath); } else { // Si le pattern n'est pas trouvé dans le nom, chercher dans le contenu if (await searchInXMindContent(fullPath, searchPattern)) { contentMatches.push(fullPath); } } } } } catch (error) { console.error(`Warning: Error searching directory ${currentDir}:`, error); } } await Promise.all(allowedDirectories.map(dir => searchInDirectory(dir))); // Combiner et trier les résultats const allMatches = [ ...matches.sort((a, b) => path.basename(a).localeCompare(path.basename(b))), ...contentMatches.sort((a, b) => path.basename(a).localeCompare(path.basename(b))) ]; return allMatches; } interface NodeSearchResult { found: boolean; node?: XMindNode; error?: string; } function findNodeByPath(node: XMindNode, searchPath: string[]): NodeSearchResult { if (searchPath.length === 0 || !searchPath[0]) { return { found: true, node }; } const currentSearch = searchPath[0].toLowerCase(); if (!node.children) { return { found: false, error: `Node "${node.title}" has no children, cannot find "${currentSearch}"` }; } const matchingChild = node.children.find( child => child.title.toLowerCase() === currentSearch ); if (!matchingChild) { return { found: false, error: `Could not find child "${currentSearch}" in node "${node.title}"` }; } return findNodeByPath(matchingChild, searchPath.slice(1)); } interface NodeMatch { id: string; title: string; path: string; sheet: string; matchedIn: string[]; notes?: string; labels?: string[]; callouts?: { title: string; }[]; taskStatus?: 'todo' | 'done'; } interface SearchResult { query: string; matches: NodeMatch[]; totalMatches: number; searchedIn: string[]; } // Ajouter la fonction de recherche de nœuds function searchNodes( node: XMindNode, query: string, options: { searchIn?: string[], caseSensitive?: boolean, taskStatus?: 'todo' | 'done' } = {}, parents: string[] = [] ): NodeMatch[] { const matches: NodeMatch[] = []; const searchQuery = options.caseSensitive ? query : query.toLowerCase(); const searchFields = options.searchIn || ['title', 'notes', 'labels', 'callouts', 'tasks']; const matchedIn: string[] = []; // Fonction helper pour la recherche de texte sécurisée const matchesText = (text: string | undefined): boolean => { if (!text) return false; const searchIn = options.caseSensitive ? text : text.toLowerCase(); return searchIn.includes(searchQuery); }; // Vérification du statut de tâche si spécifié if (options.taskStatus && node.taskStatus) { if (node.taskStatus !== options.taskStatus) { // Si le statut ne correspond pas, ignorer ce nœud return []; } } // Vérifier chaque champ configuré if (searchFields.includes('title') && matchesText(node.title)) { matchedIn.push('title'); } if (searchFields.includes('notes') && node.notes?.content && matchesText(node.notes.content)) { matchedIn.push('notes'); } if (searchFields.includes('labels') && node.labels?.some(label => matchesText(label))) { matchedIn.push('labels'); } if (searchFields.includes('callouts') && node.callouts?.some(callout => matchesText(callout.title))) { matchedIn.push('callouts'); } if (searchFields.includes('tasks') && node.taskStatus) { matchedIn.push('tasks'); } // Si on a trouvé des correspondances ou si c'est une tâche correspondante, ajouter ce nœud const shouldIncludeNode = matchedIn.length > 0 || (options.taskStatus && node.taskStatus === options.taskStatus); if (shouldIncludeNode && node.id) { matches.push({ id: node.id, title: node.title, path: getNodePath(node, parents), sheet: node.sheetTitle || 'Untitled Map', matchedIn, notes: node.notes?.content, labels: node.labels, callouts: node.callouts, taskStatus: node.taskStatus // Ajout du statut de tâche dans les résultats }); } // Rechercher récursivement dans les enfants if (node.children) { const currentPath = [...parents, node.title]; node.children.forEach(child => { matches.push(...searchNodes(child, query, options, currentPath)); }); } return matches; } // Modifier la fonction de récupération d'un nœud pour utiliser l'ID function findNodeById(node: XMindNode, searchId: string): NodeSearchResult { if (node.id === searchId) { return { found: true, node }; } if (!node.children) { return { found: false }; } for (const child of node.children) { const result = findNodeById(child, searchId); if (result.found) { return result; } } return { found: false }; } // Nouvelle interface pour les résultats de recherche de chemin interface PathSearchResult { found: boolean; nodes: Array<{ node: XMindNode; matchConfidence: number; path: string; }>; error?: string; } // Nouvelle fonction de recherche de nœuds par chemin approximatif function findNodesbyFuzzyPath( node: XMindNode, searchQuery: string, parents: string[] = [], threshold: number = 0.5 ): PathSearchResult['nodes'] { const results: PathSearchResult['nodes'] = []; const currentPath = getNodePath(node, parents); // Fonction helper pour calculer la pertinence function calculateRelevance(nodePath: string, query: string): number { const pathLower = nodePath.toLowerCase(); const queryLower = query.toLowerCase(); // Score plus élevé pour une correspondance exacte if (pathLower.includes(queryLower)) { return 1.0; } // Score basé sur les mots correspondants const pathWords = pathLower.split(/[\s>]+/); const queryWords = queryLower.split(/[\s>]+/); const matchingWords = queryWords.filter(word => pathWords.some(pathWord => pathWord.includes(word)) ); return matchingWords.length / queryWords.length; } // Vérifier le nœud courant const confidence = calculateRelevance(currentPath, searchQuery); if (confidence > threshold) { results.push({ node, matchConfidence: confidence, path: currentPath }); } // Rechercher récursivement dans les enfants if (node.children) { const newParents = [...parents, node.title]; node.children.forEach(child => { results.push(...findNodesbyFuzzyPath(child, searchQuery, newParents, threshold)); }); } return results; } // Server setup const server = new Server( { name: "xmind-analysis-server", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "read_xmind", description: `Parse and analyze XMind files with multiple capabilities: - Extract complete mind map structure in JSON format - Include all relationships between nodes with their IDs and titles - Extract callouts attached to topics - Generate text or markdown summaries - Search for specific content - Get hierarchical path to any node - Filter content by labels, task status, or node depth - Extract all URLs and external references - Analyze relationships and connections between topics Input: File path to .xmind file Output: JSON structure containing nodes, relationships, and callouts`, inputSchema: zodToJsonSchema(ReadXMindArgsSchema), }, { name: "list_xmind_directory", description: `Comprehensive XMind file discovery and analysis tool: - Recursively scan directories for .xmind files - Filter files by creation/modification date - Search for files containing specific content - Group files by project or category - Detect duplicate mind maps - Generate directory statistics and summaries - Verify file integrity and structure - Monitor changes in mind map files Input: Directory path to scan Output: List of XMind files with optional metadata`, inputSchema: zodToJsonSchema(ListXMindDirectoryArgsSchema), }, { name: "read_multiple_xmind_files", description: `Advanced multi-file analysis and correlation tool: - Process multiple XMind files simultaneously - Compare content across different mind maps - Identify common themes and patterns - Merge related content from different files - Generate cross-reference reports - Find content duplications across files - Create consolidated summaries - Track changes across multiple versions - Generate comparative analysis Input: Array of file paths to .xmind files Output: Combined analysis results in JSON format with per-file details`, inputSchema: zodToJsonSchema(ReadMultipleXMindArgsSchema), }, { name: "search_xmind_files", description: `Advanced file search tool with recursive capabilities: - Search for files and directories by partial name matching - Case-insensitive pattern matching - Searches through all subdirectories recursively - Returns full paths to all matching items - Includes both files and directories in results - Safe searching within allowed directories only - Handles special characters in names - Continues searching even if some directories are inaccessible Input: { directory: Starting directory path, pattern: Search text to match in names } Output: Array of full paths to matching items`, inputSchema: zodToJsonSchema(SearchXMindFilesSchema), }, { name: "extract_node", description: `Smart node extraction with fuzzy path matching: - Flexible search using partial or complete node paths - Returns multiple matching nodes ranked by relevance - Supports approximate matching for better results - Includes full context and hierarchy information - Returns complete subtree for each match - Best tool for exploring and navigating complex mind maps - Perfect for finding nodes when exact path is unknown Usage examples: - "Project > Backend" : finds nodes in any path containing these terms - "Feature API" : finds nodes containing these words in any order Input: { path: Path to .xmind file, searchQuery: Text to search in node paths (flexible matching) } Output: Ranked list of matching nodes with their full subtrees`, inputSchema: zodToJsonSchema(ExtractNodeArgsSchema), }, { name: "extract_node_by_id", description: `Extract a specific node and its subtree using its unique ID: - Find and extract node using its XMind ID - Return complete subtree structure - Preserve all node properties and relationships - Fast direct access without path traversal Note: For a more detailed view with fuzzy matching, use "extract_node" with the node's path Input: { path: Path to .xmind file, nodeId: Unique identifier of the node } Output: JSON structure of the found node and its subtree`, inputSchema: zodToJsonSchema(ExtractNodeByIdArgsSchema), }, { name: "search_nodes", description: `Advanced node search with multiple criteria: - Search through titles, notes, labels, callouts and tasks - Filter by task status (todo/done) - Find nodes by their relationships - Configure which fields to search in - Case-sensitive or insensitive search - Get full context including task status - Returns all matching nodes with their IDs - Includes relationship information and task status Input: { path: Path to .xmind file, query: Search text, searchIn: Array of fields to search in ['title', 'notes', 'labels', 'callouts', 'tasks'], taskStatus: 'todo' | 'done' (optional), caseSensitive: Boolean (optional) } Output: Detailed search results with task status and context`, inputSchema: zodToJsonSchema(SearchNodesArgsSchema), }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; switch (name) { case "read_xmind": { const parsed = ReadXMindArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for read_xmind: ${parsed.error}`); } if (!isPathAllowed(parsed.data.path)) { throw new Error(`Access denied: ${parsed.data.path} is not in an allowed directory`); } const parser = new XMindParser(parsed.data.path); const mindmap = await parser.parse(); return { content: [{ type: "text", text: JSON.stringify(mindmap, null, 2) }], }; } case "list_xmind_directory": { const parsed = ListXMindDirectoryArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for list_xmind_directory: ${parsed.error}`); } const files = await listXMindFiles(parsed.data.directory); return { content: [{ type: "text", text: files.join('\n') }], }; } case "read_multiple_xmind_files": { const parsed = ReadMultipleXMindArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for read_multiple_xmind_files: ${parsed.error}`); } const results = await readMultipleXMindFiles(parsed.data.paths); return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], }; } case "search_xmind_files": { const parsed = SearchXMindFilesSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for search_xmind_files: ${parsed.error}`); } // Corriger l'appel pour n'utiliser que le pattern const matches = await searchXMindFiles(parsed.data.pattern); return { content: [{ type: "text", text: matches.join('\n') }], }; } case "extract_node": { const parsed = ExtractNodeArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for extract_node: ${parsed.error}`); } const parser = new XMindParser(parsed.data.path); const mindmap = await parser.parse(); const allMatches = mindmap.flatMap(sheet => findNodesbyFuzzyPath(sheet, parsed.data.searchQuery) ); // Trier par pertinence allMatches.sort((a, b) => b.matchConfidence - a.matchConfidence); if (allMatches.length === 0) { throw new Error(`No nodes found matching: ${parsed.data.searchQuery}`); } // Retourner le résultat avec les meilleurs matchs return { content: [{ type: "text", text: JSON.stringify({ matches: allMatches.slice(0, 5), // Limiter aux 5 meilleurs résultats totalMatches: allMatches.length, query: parsed.data.searchQuery }, null, 2) }], }; } case "extract_node_by_id": { const parsed = ExtractNodeByIdArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for extract_node_by_id: ${parsed.error}`); } const parser = new XMindParser(parsed.data.path); const mindmap = await parser.parse(); for (const sheet of mindmap) { const result = findNodeById(sheet, parsed.data.nodeId); if (result.found && result.node) { return { content: [{ type: "text", text: JSON.stringify(result.node, null, 2) }], }; } } throw new Error(`Node not found with ID: ${parsed.data.nodeId}`); } case "search_nodes": { const parsed = SearchNodesArgsSchema.safeParse(args); if (!parsed.success) { throw new Error(`Invalid arguments for search_nodes: ${parsed.error}`); } const parser = new XMindParser(parsed.data.path); const mindmap = await parser.parse(); const matches: NodeMatch[] = mindmap.flatMap(sheet => searchNodes(sheet, parsed.data.query, { searchIn: parsed.data.searchIn, caseSensitive: parsed.data.caseSensitive, taskStatus: parsed.data.taskStatus }) ); const result: SearchResult = { query: parsed.data.query, matches, totalMatches: matches.length, searchedIn: parsed.data.searchIn || ['title', 'notes', 'labels', 'callouts', 'tasks'] }; return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true, }; } }); // Start server async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("XMind Analysis Server running on stdio"); console.error("Allowed directories:", allowedDirectories); } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); });