# 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 | [](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 | });
```