#
tokens: 10886/50000 6/6 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── CHANGELOG.md
├── index.ts
├── package.json
├── README.md
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | Sample
 2 | 
 3 | # Dependencies
 4 | node_modules/
 5 | npm-debug.log*
 6 | yarn-debug.log*
 7 | yarn-error.log*
 8 | .pnpm-debug.log*
 9 | package-lock.json
10 | yarn.lock
11 | .pnp.*
12 | 
13 | # Environment variables
14 | .env
15 | .env.local
16 | .env.*.local
17 | 
18 | # Build output
19 | dist/
20 | build/
21 | out/
22 | .next/
23 | .nuxt/
24 | .output/
25 | 
26 | # Coverage directory
27 | coverage/
28 | 
29 | # Cache & Logs
30 | .cache/
31 | .temp/
32 | logs
33 | *.log
34 | 
35 | # Editor directories and files
36 | .idea/
37 | .vscode/
38 | *.suo
39 | *.ntvs*
40 | *.njsproj
41 | *.sln
42 | *.sw?
43 | *.sublime-workspace
44 | *.sublime-project
45 | 
46 | # OS generated files
47 | .DS_Store
48 | .DS_Store?
49 | ._*
50 | .Spotlight-V100
51 | .Trashes
52 | ehthumbs.db
53 | Thumbs.db
54 | 
55 | # Debug
56 | npm-debug.log*
57 | yarn-debug.log*
58 | yarn-error.log*
59 | 
60 | # Optional npm cache directory
61 | .npm
62 | 
63 | # Optional eslint cache
64 | .eslintcache
65 | 
66 | # Optional stylelint cache
67 | .stylelintcache
68 | 
69 | # dotenv environment variable files
70 | .env
71 | .env.development.local
72 | .env.test.local
73 | .env.production.local
74 | .env.local
75 | 
76 | # Typescript
77 | *.tsbuildinfo
78 | next-env.d.ts
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # MCP XMind Server
  2 | [![smithery badge](https://smithery.ai/badge/@41px/mcp-xmind)](https://smithery.ai/server/@41px/mcp-xmind)
  3 | 
  4 | A Model Context Protocol server for analyzing and querying XMind mind maps. This tool provides powerful capabilities for searching, extracting, and analyzing content from XMind files.
  5 | 
  6 | ## Features
  7 | 
  8 | - 🔍 Smart fuzzy search across mind maps
  9 | - 📝 Task management and tracking
 10 | - 🌲 Hierarchical content navigation
 11 | - 🔗 Link and reference extraction
 12 | - 📊 Multi-file analysis
 13 | - 🏷️ Label and tag support
 14 | - 📂 Directory scanning
 15 | - 🔒 Secure directory access
 16 | 
 17 | ## Installation
 18 | 
 19 | ### Installing via Smithery
 20 | 
 21 | To install XMind Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@41px/mcp-xmind):
 22 | 
 23 | ```bash
 24 | npx -y @smithery/cli install @41px/mcp-xmind --client claude
 25 | ```
 26 | 
 27 | ### Manual Installation
 28 | ```bash
 29 | npm install @modelcontextprotocol/sdk adm-zip zod
 30 | npm install --save-dev typescript @types/node
 31 | ```
 32 | 
 33 | ## Usage
 34 | 
 35 | ### Starting the Server
 36 | 
 37 | ```bash
 38 | node dist/index.js <allowed-directory> [additional-directories...]
 39 | ```
 40 | 
 41 | ### Available Tools
 42 | 
 43 | 1. **read_xmind**
 44 |    - Parse and analyze XMind files
 45 |    - Extract complete mind map structure
 46 | 
 47 | 2. **get_todo_tasks**
 48 |    - Extract and analyze TODO tasks
 49 |    - Include task context and hierarchy
 50 | 
 51 | 3. **list_xmind_directory**
 52 |    - Recursively scan for XMind files
 53 |    - Filter and organize results
 54 | 
 55 | 4. **read_multiple_xmind_files**
 56 |    - Process multiple files simultaneously
 57 |    - Compare and analyze across files
 58 | 
 59 | 5. **search_xmind_files**
 60 |    - Search files by name patterns
 61 |    - Recursive directory scanning
 62 | 
 63 | 6. **extract_node**
 64 |    - Smart fuzzy path matching
 65 |    - Ranked search results
 66 |    - Complete subtree extraction
 67 | 
 68 | 7. **extract_node_by_id**
 69 |    - Direct node access by ID
 70 |    - Fast and precise retrieval
 71 | 
 72 | 8. **search_nodes**
 73 |    - Multi-criteria content search
 74 |    - Configurable search fields
 75 | 
 76 | ## Examples
 77 | 
 78 | ### Search for Nodes
 79 | ```json
 80 | {
 81 |     "name": "search_nodes",
 82 |     "arguments": {
 83 |         "path": "/path/to/file.xmind",
 84 |         "query": "project",
 85 |         "searchIn": ["title", "notes"],
 86 |         "caseSensitive": false
 87 |     }
 88 | }
 89 | ```
 90 | 
 91 | ### Extract Node
 92 | ```json
 93 | {
 94 |     "name": "extract_node",
 95 |     "arguments": {
 96 |         "path": "/path/to/file.xmind",
 97 |         "searchQuery": "Feature > API"
 98 |     }
 99 | }
100 | ```
101 | 
102 | ### List Tasks
103 | ```json
104 | {
105 |     "name": "get_todo_tasks",
106 |     "arguments": {
107 |         "path": "/path/to/file.xmind"
108 |     }
109 | }
110 | ```
111 | 
112 | ## Configuration
113 | 
114 | ### Development Configuration
115 | 
116 | Example `claude_desktop_config.json` for development:
117 | 
118 | ```json
119 | {
120 |   "xmind": {
121 |     "command": "node",
122 |     "args": [
123 |       "/Users/alex/Src/mcp-xmind/dist/index.js",
124 |       "/Users/alex/XMind"
125 |     ]
126 |   }
127 | }
128 | ```
129 | 
130 | ### Production Configuration
131 | 
132 | Example `claude_desktop_config.json` for production using npmjs:
133 | 
134 | ```json
135 | {
136 |   "xmind": {
137 |     "command": "npx",
138 |     "args": [
139 |       "-y",
140 |       "@41px/mcp-xmind",
141 |       "/Users/alex/XMind"
142 |     ]
143 |   }
144 | }
145 | ```
146 | 
147 | ## Security
148 | 
149 | - Only allows access to specified directories
150 | - Path normalization and validation
151 | - Error handling for invalid access attempts
152 | 
153 | ## Development
154 | 
155 | ### Building
156 | ```bash
157 | npm run build
158 | ```
159 | 
160 | ### Type Checking
161 | ```bash
162 | npm run type-check
163 | ```
164 | 
165 | ### MCP Inspector
166 | 
167 | ```bash
168 | npx @modelcontextprotocol/inspector node dist/index.js /Users/alex/XMind
169 | ```
170 | 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |     "compilerOptions": {
 3 |         "outDir": "./dist",
 4 |         "rootDir": ".",
 5 |         "target": "ES2022",
 6 |         "module": "Node16",
 7 |         "moduleResolution": "Node16",
 8 |         "strict": true,
 9 |         "esModuleInterop": true,
10 |         "skipLibCheck": true,
11 |         "forceConsistentCasingInFileNames": true,
12 |         "resolveJsonModule": true
13 |     },
14 |     "include": [
15 |         "./**/*.ts"
16 |     ],
17 |     "exclude": ["node_modules", "Sample"]
18 | }
```

--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Changelog
 2 | 
 3 | ## [1.1.1] - 2024-01-20
 4 | 
 5 | ### Added
 6 | - Support for node relationships
 7 | - Enhanced search with task status filtering
 8 | - Improved callouts support
 9 | 
10 | ### Changed
11 | - Removed get_todo_tasks in favor of search_nodes with status filter
12 | - Optimized file searching
13 | - Improved tool descriptions
14 | 
15 | ### Fixed
16 | - Fixed relationship parsing in content.json
17 | - Better file path handling
18 | 
19 | ## [1.0.0] - 2024-01-19
20 | 
21 | ### Added
22 | - Initial release
23 | - Basic XMind file support
24 | - Node and task extraction
25 | - File searching capabilities
26 | 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "@41px/mcp-xmind",
 3 |   "version": "1.1.1",
 4 |   "description": "MCP server for XMind",
 5 |   "license": "MIT",
 6 |   "author": "Alexandre Peyroux <[email protected]>",
 7 |   "type": "module",
 8 |   "bin": {
 9 |     "mcp-server-xmind": "dist/index.js"
10 |   },
11 |   "files": [
12 |     "dist"
13 |   ],
14 |   "scripts": {
15 |     "build": "tsc && shx chmod +x dist/*.js",
16 |     "prepare": "npm run build",
17 |     "watch": "tsc --watch"
18 |   },
19 |   "dependencies": {
20 |     "@modelcontextprotocol/sdk": "^0.5.0",
21 |     "adm-zip": "^0.5.16",
22 |     "diff": "^5.1.0",
23 |     "glob": "^10.3.10",
24 |     "minimatch": "^10.0.1",
25 |     "zod": "^3.24.1",
26 |     "zod-to-json-schema": "^3.24.1"
27 |   },
28 |   "devDependencies": {
29 |     "@types/adm-zip": "^0.5.7",
30 |     "@types/diff": "^5.0.9",
31 |     "@types/minimatch": "^5.1.2",
32 |     "@types/node": "^20.17.10",
33 |     "shx": "^0.3.4",
34 |     "typescript": "^5.7.2"
35 |   }
36 | }
37 | 
```

--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | 
  3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
  4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  5 | import {
  6 |     CallToolRequestSchema,
  7 |     ListToolsRequestSchema,
  8 |     ToolSchema,
  9 | } from "@modelcontextprotocol/sdk/types.js";
 10 | import fs from "fs/promises";
 11 | import path from "path";
 12 | import { z } from "zod";
 13 | import { zodToJsonSchema } from "zod-to-json-schema";
 14 | import AdmZip from 'adm-zip';
 15 | 
 16 | // Command line argument parsing
 17 | const args = process.argv.slice(2);
 18 | if (args.length === 0) {
 19 |     console.error("Usage: mcp-server-xmind <allowed-directory> [additional-directories...]");
 20 |     process.exit(1);
 21 | }
 22 | 
 23 | // Store allowed directories in normalized form
 24 | const allowedDirectories = args.map(dir =>
 25 |     path.normalize(path.resolve(dir)).toLowerCase()
 26 | );
 27 | 
 28 | // Validate that all directories exist and are accessible
 29 | await Promise.all(args.map(async (dir) => {
 30 |     try {
 31 |         const stats = await fs.stat(dir);
 32 |         if (!stats.isDirectory()) {
 33 |             console.error(`Error: ${dir} is not a directory`);
 34 |             process.exit(1);
 35 |         }
 36 |     } catch (error) {
 37 |         console.error(`Error accessing directory ${dir}:`, error);
 38 |         process.exit(1);
 39 |     }
 40 | }));
 41 | 
 42 | // Ajouter après la définition des allowedDirectories
 43 | function isPathAllowed(filePath: string): boolean {
 44 |     const normalizedPath = path.normalize(path.resolve(filePath)).toLowerCase();
 45 |     return allowedDirectories.some(dir => normalizedPath.startsWith(dir));
 46 | }
 47 | 
 48 | // XMind Interfaces
 49 | interface XMindNode {
 50 |     title: string;
 51 |     id?: string;
 52 |     children?: XMindNode[];
 53 |     taskStatus?: 'done' | 'todo';
 54 |     notes?: {
 55 |         content?: string;
 56 |     };
 57 |     href?: string;
 58 |     labels?: string[];
 59 |     sheetTitle?: string;
 60 |     callouts?: {
 61 |         title: string;
 62 |     }[];
 63 |     relationships?: XMindRelationship[];
 64 | }
 65 | 
 66 | interface XMindTopic {
 67 |     id: string;
 68 |     title: string;
 69 |     children?: {
 70 |         attached: XMindTopic[];
 71 |         callout?: XMindTopic[];
 72 |     };
 73 |     extensions?: Array<{
 74 |         provider: string;
 75 |         content: {
 76 |             status: 'done' | 'todo';
 77 |         };
 78 |     }>;
 79 |     notes?: {
 80 |         plain?: {
 81 |             content: string;
 82 |         };
 83 |         realHTML?: {
 84 |             content: string;
 85 |         };
 86 |     };
 87 |     href?: string;
 88 |     labels?: string[];
 89 | }
 90 | 
 91 | interface XMindRelationship {
 92 |     id: string;
 93 |     end1Id: string;
 94 |     end2Id: string;
 95 |     title?: string;
 96 | }
 97 | 
 98 | // Class XMindParser
 99 | class XMindParser {
100 |     private filePath: string;
101 | 
102 |     constructor(filePath: string) {
103 |         const resolvedPath = path.resolve(filePath);
104 |         if (!isPathAllowed(resolvedPath)) {
105 |             throw new Error(`Access denied: ${filePath} is not in an allowed directory`);
106 |         }
107 |         this.filePath = resolvedPath;
108 |     }
109 | 
110 |     public async parse(): Promise<XMindNode[]> {
111 |         const contentJson = this.extractContentJson();
112 |         return this.parseContentJson(contentJson);
113 |     }
114 | 
115 |     private extractContentJson(): string {
116 |         try {
117 |             const zip = new AdmZip(this.filePath);
118 |             const contentEntry = zip.getEntry("content.json");
119 |             if (!contentEntry) {
120 |                 throw new Error("content.json not found in XMind file");
121 |             }
122 |             return zip.readAsText(contentEntry);
123 |         } catch (error) {
124 |             throw new Error(`Failed to extract content.json: ${error}`);
125 |         }
126 |     }
127 | 
128 |     private parseContentJson(jsonContent: string): Promise<XMindNode[]> {
129 |         try {
130 |             const content = JSON.parse(jsonContent);
131 |             const allNodes = content.map((sheet: { 
132 |                 rootTopic: XMindTopic; 
133 |                 title?: string;
134 |                 relationships?: XMindRelationship[];
135 |             }) => {
136 |                 const rootNode = this.processNode(sheet.rootTopic, sheet.title || "Untitled Map");
137 |                 // Ajouter les relations au nœud racine
138 |                 if (sheet.relationships) {
139 |                     rootNode.relationships = sheet.relationships;
140 |                 }
141 |                 return rootNode;
142 |             });
143 |             return Promise.resolve(allNodes);
144 |         } catch (error) {
145 |             return Promise.reject(`Failed to parse JSON content: ${error}`);
146 |         }
147 |     }
148 | 
149 |     private processNode(node: XMindTopic, sheetTitle?: string): XMindNode {
150 |         const processedNode: XMindNode = {
151 |             title: node.title,
152 |             id: node.id,
153 |             sheetTitle: sheetTitle || "Untitled Map"
154 |         };
155 | 
156 |         // Handle links, labels and callouts
157 |         if (node.href) processedNode.href = node.href;
158 |         if (node.labels) processedNode.labels = node.labels;
159 |         if (node.children?.callout) {
160 |             processedNode.callouts = node.children.callout.map(callout => ({
161 |                 title: callout.title
162 |             }));
163 |         }
164 | 
165 |         // Handle notes and callouts
166 |         if (node.notes?.plain?.content) {
167 |             processedNode.notes = {};
168 | 
169 |             // Process main note content
170 |             if (node.notes?.plain?.content) {
171 |                 processedNode.notes.content = node.notes.plain.content;
172 |             }
173 |         }
174 | 
175 |         // Handle task status
176 |         if (node.extensions) {
177 |             const taskExtension = node.extensions.find((ext) =>
178 |                 ext.provider === 'org.xmind.ui.task' && ext.content?.status
179 |             );
180 |             if (taskExtension) {
181 |                 processedNode.taskStatus = taskExtension.content.status;
182 |             }
183 |         }
184 | 
185 |         // Process regular children
186 |         if (node.children?.attached) {
187 |             processedNode.children = node.children.attached.map(child =>
188 |                 this.processNode(child, sheetTitle)
189 |             );
190 |         }
191 | 
192 |         return processedNode;
193 |     }
194 | }
195 | 
196 | function getNodePath(node: XMindNode, parents: string[] = []): string {
197 |     return parents.length > 0 ? `${parents.join(' > ')} > ${node.title}` : node.title;
198 | }
199 | 
200 | // Schema definitions
201 | const ReadXMindArgsSchema = z.object({
202 |     path: z.string(),
203 | });
204 | 
205 | const ListXMindDirectoryArgsSchema = z.object({
206 |     directory: z.string().optional(),
207 | });
208 | 
209 | const ReadMultipleXMindArgsSchema = z.object({
210 |     paths: z.array(z.string()),
211 | });
212 | 
213 | const SearchXMindFilesSchema = z.object({
214 |     pattern: z.string(),
215 |     directory: z.string().optional(),
216 | });
217 | 
218 | // Modifier le schéma pour refléter la nouvelle approche
219 | const ExtractNodeArgsSchema = z.object({
220 |     path: z.string(),
221 |     searchQuery: z.string(), // Renommé de nodePath à searchQuery
222 | });
223 | 
224 | const ExtractNodeByIdArgsSchema = z.object({
225 |     path: z.string(),
226 |     nodeId: z.string(),
227 | });
228 | 
229 | const SearchNodesArgsSchema = z.object({
230 |     path: z.string(),
231 |     query: z.string(),
232 |     searchIn: z.array(z.enum(['title', 'notes', 'labels', 'callouts', 'tasks'])).optional(),
233 |     caseSensitive: z.boolean().optional(),
234 |     taskStatus: z.enum(['todo', 'done']).optional(), // Ajout du filtre de statut de tâche
235 | });
236 | 
237 | interface MultipleXMindResult {
238 |     filePath: string;
239 |     content: XMindNode[];
240 |     error?: string;
241 | }
242 | 
243 | async function readMultipleXMindFiles(paths: string[]): Promise<MultipleXMindResult[]> {
244 |     const results: MultipleXMindResult[] = [];
245 | 
246 |     for (const filePath of paths) {
247 |         if (!isPathAllowed(filePath)) {
248 |             results.push({
249 |                 filePath,
250 |                 content: [],
251 |                 error: `Access denied: ${filePath} is not in an allowed directory`
252 |             });
253 |             continue;
254 |         }
255 |         try {
256 |             const parser = new XMindParser(filePath);
257 |             const content = await parser.parse();
258 |             results.push({ filePath, content });
259 |         } catch (error) {
260 |             results.push({
261 |                 filePath,
262 |                 content: [],
263 |                 error: error instanceof Error ? error.message : String(error)
264 |             });
265 |         }
266 |     }
267 | 
268 |     return results;
269 | }
270 | 
271 | // Function to list XMind files
272 | async function listXMindFiles(directory?: string): Promise<string[]> {
273 |     const files: string[] = [];
274 |     const dirsToScan = directory
275 |         ? [path.normalize(path.resolve(directory))]
276 |         : allowedDirectories;
277 | 
278 |     for (const dir of dirsToScan) {
279 |         // Check if directory is allowed
280 |         const normalizedDir = dir.toLowerCase();
281 |         if (!allowedDirectories.some(allowed => normalizedDir.startsWith(allowed))) {
282 |             continue; // Skip unauthorized directories
283 |         }
284 | 
285 |         async function scanDirectory(currentDir: string) {
286 |             try {
287 |                 const entries = await fs.readdir(currentDir, { withFileTypes: true });
288 |                 for (const entry of entries) {
289 |                     const fullPath = path.join(currentDir, entry.name);
290 |                     if (entry.isDirectory()) {
291 |                         await scanDirectory(fullPath);
292 |                     } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xmind')) {
293 |                         files.push(fullPath);
294 |                     }
295 |                 }
296 |             } catch (error) {
297 |                 console.error(`Warning: Error scanning directory ${currentDir}:`, error);
298 |                 // Continue scanning other directories even if one fails
299 |             }
300 |         }
301 | 
302 |         await scanDirectory(dir);
303 |     }
304 | 
305 |     return files;
306 | }
307 | 
308 | // Add before server setup
309 | async function searchInXMindContent(filePath: string, searchText: string): Promise<boolean> {
310 |     try {
311 |         const zip = new AdmZip(filePath);
312 |         const contentEntry = zip.getEntry("content.json");
313 |         if (!contentEntry) return false;
314 | 
315 |         const content = zip.readAsText(contentEntry);
316 |         return content.toLowerCase().includes(searchText.toLowerCase());
317 |     } catch (error) {
318 |         console.error(`Error reading XMind file ${filePath}:`, error);
319 |         return false;
320 |     }
321 | }
322 | 
323 | // Modification de la fonction searchXMindFiles
324 | async function searchXMindFiles(pattern: string): Promise<string[]> {
325 |     const matches: string[] = [];
326 |     const contentMatches: string[] = [];
327 |     const searchPattern = pattern.toLowerCase();
328 | 
329 |     async function searchInDirectory(currentDir: string) {
330 |         try {
331 |             const entries = await fs.readdir(currentDir, { withFileTypes: true });
332 |             for (const entry of entries) {
333 |                 const fullPath = path.join(currentDir, entry.name);
334 | 
335 |                 if (entry.isDirectory()) {
336 |                     const normalizedPath = path.normalize(fullPath).toLowerCase();
337 |                     if (allowedDirectories.some(allowed => normalizedPath.startsWith(allowed))) {
338 |                         await searchInDirectory(fullPath);
339 |                     }
340 |                 } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.xmind')) {
341 |                     const searchableText = [
342 |                         entry.name.toLowerCase(),
343 |                         path.basename(entry.name, '.xmind').toLowerCase(),
344 |                         fullPath.toLowerCase()
345 |                     ];
346 | 
347 |                     if (searchPattern === '' || 
348 |                         searchableText.some(text => text.includes(searchPattern))) {
349 |                         matches.push(fullPath);
350 |                     } else {
351 |                         // Si le pattern n'est pas trouvé dans le nom, chercher dans le contenu
352 |                         if (await searchInXMindContent(fullPath, searchPattern)) {
353 |                             contentMatches.push(fullPath);
354 |                         }
355 |                     }
356 |                 }
357 |             }
358 |         } catch (error) {
359 |             console.error(`Warning: Error searching directory ${currentDir}:`, error);
360 |         }
361 |     }
362 | 
363 |     await Promise.all(allowedDirectories.map(dir => searchInDirectory(dir)));
364 | 
365 |     // Combiner et trier les résultats
366 |     const allMatches = [
367 |         ...matches.sort((a, b) => path.basename(a).localeCompare(path.basename(b))),
368 |         ...contentMatches.sort((a, b) => path.basename(a).localeCompare(path.basename(b)))
369 |     ];
370 | 
371 |     return allMatches;
372 | }
373 | 
374 | interface NodeSearchResult {
375 |     found: boolean;
376 |     node?: XMindNode;
377 |     error?: string;
378 | }
379 | 
380 | function findNodeByPath(node: XMindNode, searchPath: string[]): NodeSearchResult {
381 |     if (searchPath.length === 0 || !searchPath[0]) {
382 |         return { found: true, node };
383 |     }
384 | 
385 |     const currentSearch = searchPath[0].toLowerCase();
386 | 
387 |     if (!node.children) {
388 |         return {
389 |             found: false,
390 |             error: `Node "${node.title}" has no children, cannot find "${currentSearch}"`
391 |         };
392 |     }
393 | 
394 |     const matchingChild = node.children.find(
395 |         child => child.title.toLowerCase() === currentSearch
396 |     );
397 | 
398 |     if (!matchingChild) {
399 |         return {
400 |             found: false,
401 |             error: `Could not find child "${currentSearch}" in node "${node.title}"`
402 |         };
403 |     }
404 | 
405 |     return findNodeByPath(matchingChild, searchPath.slice(1));
406 | }
407 | 
408 | interface NodeMatch {
409 |     id: string;
410 |     title: string;
411 |     path: string;
412 |     sheet: string;
413 |     matchedIn: string[];
414 |     notes?: string;
415 |     labels?: string[];
416 |     callouts?: {
417 |         title: string;
418 |     }[];
419 |     taskStatus?: 'todo' | 'done';
420 | }
421 | 
422 | interface SearchResult {
423 |     query: string;
424 |     matches: NodeMatch[];
425 |     totalMatches: number;
426 |     searchedIn: string[];
427 | }
428 | 
429 | // Ajouter la fonction de recherche de nœuds
430 | function searchNodes(
431 |     node: XMindNode,
432 |     query: string,
433 |     options: {
434 |         searchIn?: string[],
435 |         caseSensitive?: boolean,
436 |         taskStatus?: 'todo' | 'done'
437 |     } = {},
438 |     parents: string[] = []
439 | ): NodeMatch[] {
440 |     const matches: NodeMatch[] = [];
441 |     const searchQuery = options.caseSensitive ? query : query.toLowerCase();
442 |     const searchFields = options.searchIn || ['title', 'notes', 'labels', 'callouts', 'tasks'];
443 | 
444 |     const matchedIn: string[] = [];
445 | 
446 |     // Fonction helper pour la recherche de texte sécurisée
447 |     const matchesText = (text: string | undefined): boolean => {
448 |         if (!text) return false;
449 |         const searchIn = options.caseSensitive ? text : text.toLowerCase();
450 |         return searchIn.includes(searchQuery);
451 |     };
452 | 
453 |     // Vérification du statut de tâche si spécifié
454 |     if (options.taskStatus && node.taskStatus) {
455 |         if (node.taskStatus !== options.taskStatus) {
456 |             // Si le statut ne correspond pas, ignorer ce nœud
457 |             return [];
458 |         }
459 |     }
460 | 
461 |     // Vérifier chaque champ configuré
462 |     if (searchFields.includes('title') && matchesText(node.title)) {
463 |         matchedIn.push('title');
464 |     }
465 |     if (searchFields.includes('notes') && node.notes?.content && matchesText(node.notes.content)) {
466 |         matchedIn.push('notes');
467 |     }
468 |     if (searchFields.includes('labels') && node.labels?.some(label => matchesText(label))) {
469 |         matchedIn.push('labels');
470 |     }
471 |     if (searchFields.includes('callouts') && node.callouts?.some(callout => matchesText(callout.title))) {
472 |         matchedIn.push('callouts');
473 |     }
474 |     if (searchFields.includes('tasks') && node.taskStatus) {
475 |         matchedIn.push('tasks');
476 |     }
477 | 
478 |     // Si on a trouvé des correspondances ou si c'est une tâche correspondante, ajouter ce nœud
479 |     const shouldIncludeNode = matchedIn.length > 0 || 
480 |         (options.taskStatus && node.taskStatus === options.taskStatus);
481 | 
482 |     if (shouldIncludeNode && node.id) {
483 |         matches.push({
484 |             id: node.id,
485 |             title: node.title,
486 |             path: getNodePath(node, parents),
487 |             sheet: node.sheetTitle || 'Untitled Map',
488 |             matchedIn,
489 |             notes: node.notes?.content,
490 |             labels: node.labels,
491 |             callouts: node.callouts,
492 |             taskStatus: node.taskStatus // Ajout du statut de tâche dans les résultats
493 |         });
494 |     }
495 | 
496 |     // Rechercher récursivement dans les enfants
497 |     if (node.children) {
498 |         const currentPath = [...parents, node.title];
499 |         node.children.forEach(child => {
500 |             matches.push(...searchNodes(child, query, options, currentPath));
501 |         });
502 |     }
503 | 
504 |     return matches;
505 | }
506 | 
507 | // Modifier la fonction de récupération d'un nœud pour utiliser l'ID
508 | function findNodeById(node: XMindNode, searchId: string): NodeSearchResult {
509 |     if (node.id === searchId) {
510 |         return { found: true, node };
511 |     }
512 | 
513 |     if (!node.children) {
514 |         return { found: false };
515 |     }
516 | 
517 |     for (const child of node.children) {
518 |         const result = findNodeById(child, searchId);
519 |         if (result.found) {
520 |             return result;
521 |         }
522 |     }
523 | 
524 |     return { found: false };
525 | }
526 | 
527 | // Nouvelle interface pour les résultats de recherche de chemin
528 | interface PathSearchResult {
529 |     found: boolean;
530 |     nodes: Array<{
531 |         node: XMindNode;
532 |         matchConfidence: number;
533 |         path: string;
534 |     }>;
535 |     error?: string;
536 | }
537 | 
538 | // Nouvelle fonction de recherche de nœuds par chemin approximatif
539 | function findNodesbyFuzzyPath(
540 |     node: XMindNode,
541 |     searchQuery: string,
542 |     parents: string[] = [],
543 |     threshold: number = 0.5
544 | ): PathSearchResult['nodes'] {
545 |     const results: PathSearchResult['nodes'] = [];
546 |     const currentPath = getNodePath(node, parents);
547 | 
548 |     // Fonction helper pour calculer la pertinence
549 |     function calculateRelevance(nodePath: string, query: string): number {
550 |         const pathLower = nodePath.toLowerCase();
551 |         const queryLower = query.toLowerCase();
552 | 
553 |         // Score plus élevé pour une correspondance exacte
554 |         if (pathLower.includes(queryLower)) {
555 |             return 1.0;
556 |         }
557 | 
558 |         // Score basé sur les mots correspondants
559 |         const pathWords = pathLower.split(/[\s>]+/);
560 |         const queryWords = queryLower.split(/[\s>]+/);
561 | 
562 |         const matchingWords = queryWords.filter(word =>
563 |             pathWords.some(pathWord => pathWord.includes(word))
564 |         );
565 | 
566 |         return matchingWords.length / queryWords.length;
567 |     }
568 | 
569 |     // Vérifier le nœud courant
570 |     const confidence = calculateRelevance(currentPath, searchQuery);
571 |     if (confidence > threshold) {
572 |         results.push({
573 |             node,
574 |             matchConfidence: confidence,
575 |             path: currentPath
576 |         });
577 |     }
578 | 
579 |     // Rechercher récursivement dans les enfants
580 |     if (node.children) {
581 |         const newParents = [...parents, node.title];
582 |         node.children.forEach(child => {
583 |             results.push(...findNodesbyFuzzyPath(child, searchQuery, newParents, threshold));
584 |         });
585 |     }
586 | 
587 |     return results;
588 | }
589 | 
590 | // Server setup
591 | const server = new Server(
592 |     {
593 |         name: "xmind-analysis-server",
594 |         version: "1.0.0",
595 |     },
596 |     {
597 |         capabilities: {
598 |             tools: {},
599 |         },
600 |     }
601 | );
602 | 
603 | // Tool handlers
604 | server.setRequestHandler(ListToolsRequestSchema, async () => {
605 |     return {
606 |         tools: [
607 |             {
608 |                 name: "read_xmind",
609 |                 description: `Parse and analyze XMind files with multiple capabilities:
610 |                 - Extract complete mind map structure in JSON format
611 |                 - Include all relationships between nodes with their IDs and titles
612 |                 - Extract callouts attached to topics
613 |                 - Generate text or markdown summaries
614 |                 - Search for specific content
615 |                 - Get hierarchical path to any node
616 |                 - Filter content by labels, task status, or node depth
617 |                 - Extract all URLs and external references
618 |                 - Analyze relationships and connections between topics
619 |                 Input: File path to .xmind file
620 |                 Output: JSON structure containing nodes, relationships, and callouts`,
621 |                 inputSchema: zodToJsonSchema(ReadXMindArgsSchema),
622 |             },
623 |             {
624 |                 name: "list_xmind_directory",
625 |                 description: `Comprehensive XMind file discovery and analysis tool:
626 |                 - Recursively scan directories for .xmind files
627 |                 - Filter files by creation/modification date
628 |                 - Search for files containing specific content
629 |                 - Group files by project or category
630 |                 - Detect duplicate mind maps
631 |                 - Generate directory statistics and summaries
632 |                 - Verify file integrity and structure
633 |                 - Monitor changes in mind map files
634 |                 Input: Directory path to scan
635 |                 Output: List of XMind files with optional metadata`,
636 |                 inputSchema: zodToJsonSchema(ListXMindDirectoryArgsSchema),
637 |             },
638 |             {
639 |                 name: "read_multiple_xmind_files",
640 |                 description: `Advanced multi-file analysis and correlation tool:
641 |                 - Process multiple XMind files simultaneously
642 |                 - Compare content across different mind maps
643 |                 - Identify common themes and patterns
644 |                 - Merge related content from different files
645 |                 - Generate cross-reference reports
646 |                 - Find content duplications across files
647 |                 - Create consolidated summaries
648 |                 - Track changes across multiple versions
649 |                 - Generate comparative analysis
650 |                 Input: Array of file paths to .xmind files
651 |                 Output: Combined analysis results in JSON format with per-file details`,
652 |                 inputSchema: zodToJsonSchema(ReadMultipleXMindArgsSchema),
653 |             },
654 |             {
655 |                 name: "search_xmind_files",
656 |                 description: `Advanced file search tool with recursive capabilities:
657 |                 - Search for files and directories by partial name matching
658 |                 - Case-insensitive pattern matching
659 |                 - Searches through all subdirectories recursively
660 |                 - Returns full paths to all matching items
661 |                 - Includes both files and directories in results
662 |                 - Safe searching within allowed directories only
663 |                 - Handles special characters in names
664 |                 - Continues searching even if some directories are inaccessible
665 |                 Input: {
666 |                     directory: Starting directory path,
667 |                     pattern: Search text to match in names
668 |                 }
669 |                 Output: Array of full paths to matching items`,
670 |                 inputSchema: zodToJsonSchema(SearchXMindFilesSchema),
671 |             },
672 |             {
673 |                 name: "extract_node",
674 |                 description: `Smart node extraction with fuzzy path matching:
675 |                 - Flexible search using partial or complete node paths
676 |                 - Returns multiple matching nodes ranked by relevance
677 |                 - Supports approximate matching for better results
678 |                 - Includes full context and hierarchy information
679 |                 - Returns complete subtree for each match
680 |                 - Best tool for exploring and navigating complex mind maps
681 |                 - Perfect for finding nodes when exact path is unknown
682 |                 Usage examples:
683 |                 - "Project > Backend" : finds nodes in any path containing these terms
684 |                 - "Feature API" : finds nodes containing these words in any order
685 |                 Input: {
686 |                     path: Path to .xmind file,
687 |                     searchQuery: Text to search in node paths (flexible matching)
688 |                 }
689 |                 Output: Ranked list of matching nodes with their full subtrees`,
690 |                 inputSchema: zodToJsonSchema(ExtractNodeArgsSchema),
691 |             },
692 |             {
693 |                 name: "extract_node_by_id",
694 |                 description: `Extract a specific node and its subtree using its unique ID:
695 |                 - Find and extract node using its XMind ID
696 |                 - Return complete subtree structure
697 |                 - Preserve all node properties and relationships
698 |                 - Fast direct access without path traversal
699 |                 Note: For a more detailed view with fuzzy matching, use "extract_node" with the node's path
700 |                 Input: {
701 |                     path: Path to .xmind file,
702 |                     nodeId: Unique identifier of the node
703 |                 }
704 |                 Output: JSON structure of the found node and its subtree`,
705 |                 inputSchema: zodToJsonSchema(ExtractNodeByIdArgsSchema),
706 |             },
707 |             {
708 |                 name: "search_nodes",
709 |                 description: `Advanced node search with multiple criteria:
710 |                 - Search through titles, notes, labels, callouts and tasks
711 |                 - Filter by task status (todo/done)
712 |                 - Find nodes by their relationships
713 |                 - Configure which fields to search in
714 |                 - Case-sensitive or insensitive search
715 |                 - Get full context including task status
716 |                 - Returns all matching nodes with their IDs
717 |                 - Includes relationship information and task status
718 |                 Input: {
719 |                     path: Path to .xmind file,
720 |                     query: Search text,
721 |                     searchIn: Array of fields to search in ['title', 'notes', 'labels', 'callouts', 'tasks'],
722 |                     taskStatus: 'todo' | 'done' (optional),
723 |                     caseSensitive: Boolean (optional)
724 |                 }
725 |                 Output: Detailed search results with task status and context`,
726 |                 inputSchema: zodToJsonSchema(SearchNodesArgsSchema),
727 |             },
728 |         ],
729 |     };
730 | });
731 | 
732 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
733 |     try {
734 |         const { name, arguments: args } = request.params;
735 | 
736 |         switch (name) {
737 |             case "read_xmind": {
738 |                 const parsed = ReadXMindArgsSchema.safeParse(args);
739 |                 if (!parsed.success) {
740 |                     throw new Error(`Invalid arguments for read_xmind: ${parsed.error}`);
741 |                 }
742 |                 if (!isPathAllowed(parsed.data.path)) {
743 |                     throw new Error(`Access denied: ${parsed.data.path} is not in an allowed directory`);
744 |                 }
745 |                 const parser = new XMindParser(parsed.data.path);
746 |                 const mindmap = await parser.parse();
747 |                 return {
748 |                     content: [{ type: "text", text: JSON.stringify(mindmap, null, 2) }],
749 |                 };
750 |             }
751 | 
752 |             case "list_xmind_directory": {
753 |                 const parsed = ListXMindDirectoryArgsSchema.safeParse(args);
754 |                 if (!parsed.success) {
755 |                     throw new Error(`Invalid arguments for list_xmind_directory: ${parsed.error}`);
756 |                 }
757 |                 const files = await listXMindFiles(parsed.data.directory);
758 |                 return {
759 |                     content: [{ type: "text", text: files.join('\n') }],
760 |                 };
761 |             }
762 | 
763 |             case "read_multiple_xmind_files": {
764 |                 const parsed = ReadMultipleXMindArgsSchema.safeParse(args);
765 |                 if (!parsed.success) {
766 |                     throw new Error(`Invalid arguments for read_multiple_xmind_files: ${parsed.error}`);
767 |                 }
768 |                 const results = await readMultipleXMindFiles(parsed.data.paths);
769 |                 return {
770 |                     content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
771 |                 };
772 |             }
773 | 
774 |             case "search_xmind_files": {
775 |                 const parsed = SearchXMindFilesSchema.safeParse(args);
776 |                 if (!parsed.success) {
777 |                     throw new Error(`Invalid arguments for search_xmind_files: ${parsed.error}`);
778 |                 }
779 |                 // Corriger l'appel pour n'utiliser que le pattern
780 |                 const matches = await searchXMindFiles(parsed.data.pattern);
781 |                 return {
782 |                     content: [{ type: "text", text: matches.join('\n') }],
783 |                 };
784 |             }
785 | 
786 |             case "extract_node": {
787 |                 const parsed = ExtractNodeArgsSchema.safeParse(args);
788 |                 if (!parsed.success) {
789 |                     throw new Error(`Invalid arguments for extract_node: ${parsed.error}`);
790 |                 }
791 | 
792 |                 const parser = new XMindParser(parsed.data.path);
793 |                 const mindmap = await parser.parse();
794 | 
795 |                 const allMatches = mindmap.flatMap(sheet =>
796 |                     findNodesbyFuzzyPath(sheet, parsed.data.searchQuery)
797 |                 );
798 | 
799 |                 // Trier par pertinence
800 |                 allMatches.sort((a, b) => b.matchConfidence - a.matchConfidence);
801 | 
802 |                 if (allMatches.length === 0) {
803 |                     throw new Error(`No nodes found matching: ${parsed.data.searchQuery}`);
804 |                 }
805 | 
806 |                 // Retourner le résultat avec les meilleurs matchs
807 |                 return {
808 |                     content: [{
809 |                         type: "text",
810 |                         text: JSON.stringify({
811 |                             matches: allMatches.slice(0, 5), // Limiter aux 5 meilleurs résultats
812 |                             totalMatches: allMatches.length,
813 |                             query: parsed.data.searchQuery
814 |                         }, null, 2)
815 |                     }],
816 |                 };
817 |             }
818 | 
819 |             case "extract_node_by_id": {
820 |                 const parsed = ExtractNodeByIdArgsSchema.safeParse(args);
821 |                 if (!parsed.success) {
822 |                     throw new Error(`Invalid arguments for extract_node_by_id: ${parsed.error}`);
823 |                 }
824 | 
825 |                 const parser = new XMindParser(parsed.data.path);
826 |                 const mindmap = await parser.parse();
827 | 
828 |                 for (const sheet of mindmap) {
829 |                     const result = findNodeById(sheet, parsed.data.nodeId);
830 |                     if (result.found && result.node) {
831 |                         return {
832 |                             content: [{
833 |                                 type: "text",
834 |                                 text: JSON.stringify(result.node, null, 2)
835 |                             }],
836 |                         };
837 |                     }
838 |                 }
839 | 
840 |                 throw new Error(`Node not found with ID: ${parsed.data.nodeId}`);
841 |             }
842 | 
843 |             case "search_nodes": {
844 |                 const parsed = SearchNodesArgsSchema.safeParse(args);
845 |                 if (!parsed.success) {
846 |                     throw new Error(`Invalid arguments for search_nodes: ${parsed.error}`);
847 |                 }
848 | 
849 |                 const parser = new XMindParser(parsed.data.path);
850 |                 const mindmap = await parser.parse();
851 | 
852 |                 const matches: NodeMatch[] = mindmap.flatMap(sheet =>
853 |                     searchNodes(sheet, parsed.data.query, {
854 |                         searchIn: parsed.data.searchIn,
855 |                         caseSensitive: parsed.data.caseSensitive,
856 |                         taskStatus: parsed.data.taskStatus
857 |                     })
858 |                 );
859 | 
860 |                 const result: SearchResult = {
861 |                     query: parsed.data.query,
862 |                     matches,
863 |                     totalMatches: matches.length,
864 |                     searchedIn: parsed.data.searchIn || ['title', 'notes', 'labels', 'callouts', 'tasks']
865 |                 };
866 | 
867 |                 return {
868 |                     content: [{
869 |                         type: "text",
870 |                         text: JSON.stringify(result, null, 2)
871 |                     }],
872 |                 };
873 |             }
874 | 
875 |             default:
876 |                 throw new Error(`Unknown tool: ${name}`);
877 |         }
878 |     } catch (error) {
879 |         const errorMessage = error instanceof Error ? error.message : String(error);
880 |         return {
881 |             content: [{ type: "text", text: `Error: ${errorMessage}` }],
882 |             isError: true,
883 |         };
884 |     }
885 | });
886 | 
887 | // Start server
888 | async function runServer() {
889 |     const transport = new StdioServerTransport();
890 |     await server.connect(transport);
891 |     console.error("XMind Analysis Server running on stdio");
892 |     console.error("Allowed directories:", allowedDirectories);
893 | }
894 | 
895 | runServer().catch((error) => {
896 |     console.error("Fatal error running server:", error);
897 |     process.exit(1);
898 | });
```