ai-mind-map 1.12.3 → 1.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/knowledge-graph/graph.d.ts +15 -0
- package/dist/knowledge-graph/graph.d.ts.map +1 -1
- package/dist/knowledge-graph/graph.js +70 -1
- package/dist/knowledge-graph/graph.js.map +1 -1
- package/dist/knowledge-graph/indexer.d.ts.map +1 -1
- package/dist/knowledge-graph/indexer.js +22 -0
- package/dist/knowledge-graph/indexer.js.map +1 -1
- package/dist/tools/advanced-tools.d.ts.map +1 -1
- package/dist/tools/advanced-tools.js +21 -0
- package/dist/tools/advanced-tools.js.map +1 -1
- package/dist/tools/filesystem-tools.d.ts +5 -2
- package/dist/tools/filesystem-tools.d.ts.map +1 -1
- package/dist/tools/filesystem-tools.js +493 -3
- package/dist/tools/filesystem-tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* knowledge-graph-powered tools with fast, no-dependency operations.
|
|
7
7
|
*
|
|
8
8
|
* Tools:
|
|
9
|
-
* - mindmap_list_dir
|
|
10
|
-
* - mindmap_read_lines
|
|
9
|
+
* - mindmap_list_dir — Directory listing (no indexing)
|
|
10
|
+
* - mindmap_read_lines — Read specific line ranges with optional graph context
|
|
11
11
|
* - mindmap_project_summary — One-call project overview before indexing
|
|
12
|
+
* - mindmap_batch_read — Read multiple file regions in a single call
|
|
13
|
+
* - mindmap_grep — Text search across project files (like ripgrep)
|
|
14
|
+
* - mindmap_find_file — Find files by name pattern
|
|
12
15
|
*/
|
|
13
16
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
14
17
|
import type { MindMapConfig } from '../types.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filesystem-tools.d.ts","sourceRoot":"","sources":["../../src/tools/filesystem-tools.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"filesystem-tools.d.ts","sourceRoot":"","sources":["../../src/tools/filesystem-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAeH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAc,aAAa,EAAa,MAAM,aAAa,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAM7D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;CAChC;AAolBD;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,aAAa,EACrB,SAAS,GAAE,eAAkC,GAC5C,IAAI,CAwsBN"}
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* knowledge-graph-powered tools with fast, no-dependency operations.
|
|
7
7
|
*
|
|
8
8
|
* Tools:
|
|
9
|
-
* - mindmap_list_dir
|
|
10
|
-
* - mindmap_read_lines
|
|
9
|
+
* - mindmap_list_dir — Directory listing (no indexing)
|
|
10
|
+
* - mindmap_read_lines — Read specific line ranges with optional graph context
|
|
11
11
|
* - mindmap_project_summary — One-call project overview before indexing
|
|
12
|
+
* - mindmap_batch_read — Read multiple file regions in a single call
|
|
13
|
+
* - mindmap_grep — Text search across project files (like ripgrep)
|
|
14
|
+
* - mindmap_find_file — Find files by name pattern
|
|
12
15
|
*/
|
|
13
16
|
import { z } from 'zod';
|
|
14
|
-
import { readFileSync, readdirSync, statSync, existsSync, } from 'node:fs';
|
|
17
|
+
import { readFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync, } from 'node:fs';
|
|
15
18
|
import { resolve, relative, extname, basename, join, isAbsolute } from 'node:path';
|
|
16
19
|
const defaultEstimator = {
|
|
17
20
|
estimate: (text) => Math.ceil(text.length / 4),
|
|
@@ -628,5 +631,492 @@ export function registerFilesystemTools(server, graph, config, estimator = defau
|
|
|
628
631
|
return mcpText(fail(`Failed to generate project summary: ${msg}`));
|
|
629
632
|
}
|
|
630
633
|
});
|
|
634
|
+
// ── mindmap_grep ──────────────────────────────────────────────
|
|
635
|
+
/** Detect binary files by checking for null bytes in the first 512 bytes */
|
|
636
|
+
function isBinaryFile(filePath) {
|
|
637
|
+
try {
|
|
638
|
+
const buf = Buffer.alloc(512);
|
|
639
|
+
const fd = openSync(filePath, 'r');
|
|
640
|
+
const bytesRead = readSync(fd, buf, 0, 512, 0);
|
|
641
|
+
closeSync(fd);
|
|
642
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
643
|
+
if (buf[i] === 0)
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Max file size (1 MB) for grep scanning */
|
|
653
|
+
const MAX_GREP_FILE_SIZE = 1_048_576;
|
|
654
|
+
function walkForGrep(dir, fileGlob, results) {
|
|
655
|
+
let entries;
|
|
656
|
+
try {
|
|
657
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
for (const entry of entries) {
|
|
663
|
+
if (entry.name.startsWith('.'))
|
|
664
|
+
continue;
|
|
665
|
+
const fullPath = join(dir, entry.name);
|
|
666
|
+
if (entry.isDirectory()) {
|
|
667
|
+
if (SKIP_DIRS.has(entry.name))
|
|
668
|
+
continue;
|
|
669
|
+
walkForGrep(fullPath, fileGlob, results);
|
|
670
|
+
}
|
|
671
|
+
else if (entry.isFile()) {
|
|
672
|
+
if (fileGlob) {
|
|
673
|
+
const ext = extname(entry.name).toLowerCase();
|
|
674
|
+
const nameToTest = ext || entry.name;
|
|
675
|
+
if (!fileGlob.test(entry.name) && !fileGlob.test(nameToTest))
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
results.push(fullPath);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
server.tool('mindmap_grep', 'Powerful text search across project files. Searches file contents for a pattern (literal or regex) with optional context lines, file filtering, and word-boundary matching. Like ripgrep but through MCP. Returns matching lines with file paths and line numbers.', {
|
|
683
|
+
pattern: z.string().describe('Search pattern (literal text or regex)'),
|
|
684
|
+
regex: z.boolean().default(false).describe('Treat pattern as regex'),
|
|
685
|
+
fileGlob: z.string().optional().describe("Filter by file extension pattern, e.g. '*.cs', '*.ts'"),
|
|
686
|
+
caseSensitive: z.boolean().default(false).describe('Case-sensitive matching'),
|
|
687
|
+
contextLines: z.number().int().min(0).max(5).default(0).describe('Lines of context before/after each match (max 5)'),
|
|
688
|
+
maxResults: z.number().int().min(1).default(100).describe('Maximum results to return'),
|
|
689
|
+
path: z.string().optional().describe('Search within a specific subdirectory (absolute or relative to projectRoot)'),
|
|
690
|
+
invertMatch: z.boolean().default(false).describe('Return lines that do NOT match'),
|
|
691
|
+
wholeWord: z.boolean().default(false).describe('Match whole words only'),
|
|
692
|
+
}, async ({ pattern, regex: isRegex, fileGlob, caseSensitive, contextLines, maxResults, path: subPath, invertMatch, wholeWord }) => {
|
|
693
|
+
try {
|
|
694
|
+
// Determine search root
|
|
695
|
+
let searchRoot = config.projectRoot;
|
|
696
|
+
if (subPath) {
|
|
697
|
+
const resolved = resolvePath(subPath, config.projectRoot, true);
|
|
698
|
+
if (!resolved) {
|
|
699
|
+
return mcpText(fail(`Path "${subPath}" resolves outside the project root`));
|
|
700
|
+
}
|
|
701
|
+
if (!existsSync(resolved)) {
|
|
702
|
+
return mcpText(fail(`Path not found: ${resolved}`));
|
|
703
|
+
}
|
|
704
|
+
searchRoot = resolved;
|
|
705
|
+
}
|
|
706
|
+
// Build file glob regex
|
|
707
|
+
let globRegex = null;
|
|
708
|
+
if (fileGlob) {
|
|
709
|
+
const escaped = fileGlob.replace(/[.+^${}()|[\]]/g, '\\$&');
|
|
710
|
+
const globPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
711
|
+
globRegex = new RegExp(`^${globPattern}$`, 'i');
|
|
712
|
+
}
|
|
713
|
+
// Build search regex
|
|
714
|
+
let searchPattern;
|
|
715
|
+
if (isRegex) {
|
|
716
|
+
searchPattern = pattern;
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
searchPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
720
|
+
}
|
|
721
|
+
if (wholeWord) {
|
|
722
|
+
searchPattern = `\\b${searchPattern}\\b`;
|
|
723
|
+
}
|
|
724
|
+
const flags = caseSensitive ? 'g' : 'gi';
|
|
725
|
+
let searchRegex;
|
|
726
|
+
try {
|
|
727
|
+
searchRegex = new RegExp(searchPattern, flags);
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
731
|
+
return mcpText(fail(`Invalid regex pattern: ${msg}`));
|
|
732
|
+
}
|
|
733
|
+
// Collect files to search
|
|
734
|
+
const filePaths = [];
|
|
735
|
+
walkForGrep(searchRoot, globRegex, filePaths);
|
|
736
|
+
filePaths.sort();
|
|
737
|
+
// Search files
|
|
738
|
+
const results = [];
|
|
739
|
+
let totalMatches = 0;
|
|
740
|
+
const filesWithMatches = new Set();
|
|
741
|
+
let truncated = false;
|
|
742
|
+
for (const filePath of filePaths) {
|
|
743
|
+
if (results.length >= maxResults) {
|
|
744
|
+
truncated = true;
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
// Skip large files
|
|
748
|
+
try {
|
|
749
|
+
const stat = statSync(filePath);
|
|
750
|
+
if (stat.size > MAX_GREP_FILE_SIZE)
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
// Skip binary files
|
|
757
|
+
if (isBinaryFile(filePath))
|
|
758
|
+
continue;
|
|
759
|
+
let content;
|
|
760
|
+
try {
|
|
761
|
+
content = readFileSync(filePath, 'utf-8');
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const lines = content.split('\n');
|
|
767
|
+
const relativePath = relative(config.projectRoot, filePath).replace(/\\/g, '/');
|
|
768
|
+
for (let i = 0; i < lines.length; i++) {
|
|
769
|
+
if (results.length >= maxResults) {
|
|
770
|
+
truncated = true;
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
const line = lines[i];
|
|
774
|
+
searchRegex.lastIndex = 0;
|
|
775
|
+
const matches = line.match(searchRegex);
|
|
776
|
+
const hasMatch = matches !== null && matches.length > 0;
|
|
777
|
+
if (invertMatch ? !hasMatch : hasMatch) {
|
|
778
|
+
const matchCount = invertMatch ? 0 : (matches?.length ?? 0);
|
|
779
|
+
totalMatches += matchCount || 1;
|
|
780
|
+
filesWithMatches.add(relativePath);
|
|
781
|
+
const entry = {
|
|
782
|
+
file: relativePath,
|
|
783
|
+
line: line,
|
|
784
|
+
lineNumber: i + 1,
|
|
785
|
+
matchCount,
|
|
786
|
+
};
|
|
787
|
+
if (contextLines > 0) {
|
|
788
|
+
const beforeStart = Math.max(0, i - contextLines);
|
|
789
|
+
const afterEnd = Math.min(lines.length - 1, i + contextLines);
|
|
790
|
+
entry.before = lines.slice(beforeStart, i);
|
|
791
|
+
entry.after = lines.slice(i + 1, afterEnd + 1);
|
|
792
|
+
}
|
|
793
|
+
results.push(entry);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return mcpText(ok({
|
|
798
|
+
pattern,
|
|
799
|
+
totalMatches,
|
|
800
|
+
totalFiles: filePaths.length,
|
|
801
|
+
filesWithMatches: filesWithMatches.size,
|
|
802
|
+
truncated,
|
|
803
|
+
results,
|
|
804
|
+
}, estimator));
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
808
|
+
return mcpText(fail(`Grep failed: ${msg}`));
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
// ── mindmap_find_file ─────────────────────────────────────────
|
|
812
|
+
/** Convert a glob pattern (with * and ?) to a RegExp */
|
|
813
|
+
function globToRegex(glob) {
|
|
814
|
+
const escaped = glob.replace(/[.+^${}()|[\]]/g, '\\$&');
|
|
815
|
+
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
816
|
+
return new RegExp(`^${pattern}$`, 'i');
|
|
817
|
+
}
|
|
818
|
+
function walkForFind(dir, filterType, maxDepth, results, depth = 0) {
|
|
819
|
+
if (depth > maxDepth)
|
|
820
|
+
return;
|
|
821
|
+
let entries;
|
|
822
|
+
try {
|
|
823
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
for (const entry of entries) {
|
|
829
|
+
if (entry.name.startsWith('.'))
|
|
830
|
+
continue;
|
|
831
|
+
const fullPath = join(dir, entry.name);
|
|
832
|
+
if (entry.isDirectory()) {
|
|
833
|
+
if (SKIP_DIRS.has(entry.name))
|
|
834
|
+
continue;
|
|
835
|
+
if (filterType === 'dir' || filterType === 'all') {
|
|
836
|
+
results.push({ path: fullPath, name: entry.name, type: 'dir' });
|
|
837
|
+
}
|
|
838
|
+
walkForFind(fullPath, filterType, maxDepth, results, depth + 1);
|
|
839
|
+
}
|
|
840
|
+
else if (entry.isFile()) {
|
|
841
|
+
if (filterType === 'file' || filterType === 'all') {
|
|
842
|
+
results.push({ path: fullPath, name: entry.name, type: 'file' });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function scoreMatch(name, rawPattern) {
|
|
848
|
+
const nameLower = name.toLowerCase();
|
|
849
|
+
const patternLower = rawPattern.toLowerCase();
|
|
850
|
+
// Strip glob chars for literal comparisons
|
|
851
|
+
const literalPattern = rawPattern.replace(/[*?]/g, '').toLowerCase();
|
|
852
|
+
if (nameLower === literalPattern)
|
|
853
|
+
return 100;
|
|
854
|
+
if (nameLower.startsWith(literalPattern))
|
|
855
|
+
return 80;
|
|
856
|
+
if (nameLower.includes(literalPattern))
|
|
857
|
+
return 60;
|
|
858
|
+
return 40;
|
|
859
|
+
}
|
|
860
|
+
server.tool('mindmap_find_file', 'Find files by name pattern across the project. Supports wildcards (* and ?) and regex. Searches indexed files first for instant results, falls back to filesystem walk. Returns paths sorted by relevance.', {
|
|
861
|
+
pattern: z.string().describe('Filename pattern to search for (supports wildcards: * and ?)'),
|
|
862
|
+
regex: z.boolean().default(false).describe('Treat pattern as regex instead of glob'),
|
|
863
|
+
type: z.enum(['file', 'dir', 'all']).default('file').describe("Filter by type: 'file', 'dir', or 'all'"),
|
|
864
|
+
maxDepth: z.number().int().min(1).default(20).describe('Maximum directory depth to search'),
|
|
865
|
+
maxResults: z.number().int().min(1).default(50).describe('Maximum results'),
|
|
866
|
+
path: z.string().optional().describe('Search within a specific subdirectory'),
|
|
867
|
+
includeSize: z.boolean().default(true).describe('Include file size in results'),
|
|
868
|
+
includeModified: z.boolean().default(false).describe('Include last modified timestamp'),
|
|
869
|
+
sortBy: z.enum(['relevance', 'name', 'size', 'modified']).default('relevance').describe("Sort by 'relevance', 'name', 'size', or 'modified'"),
|
|
870
|
+
}, async ({ pattern, regex: isRegex, type: filterType, maxDepth, maxResults, path: subPath, includeSize, includeModified, sortBy }) => {
|
|
871
|
+
try {
|
|
872
|
+
// Determine search root
|
|
873
|
+
let searchRoot = config.projectRoot;
|
|
874
|
+
if (subPath) {
|
|
875
|
+
const resolved = resolvePath(subPath, config.projectRoot, true);
|
|
876
|
+
if (!resolved) {
|
|
877
|
+
return mcpText(fail(`Path "${subPath}" resolves outside the project root`));
|
|
878
|
+
}
|
|
879
|
+
if (!existsSync(resolved)) {
|
|
880
|
+
return mcpText(fail(`Path not found: ${resolved}`));
|
|
881
|
+
}
|
|
882
|
+
searchRoot = resolved;
|
|
883
|
+
}
|
|
884
|
+
// Build match regex
|
|
885
|
+
let matchRegex;
|
|
886
|
+
try {
|
|
887
|
+
if (isRegex) {
|
|
888
|
+
matchRegex = new RegExp(pattern, 'i');
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
matchRegex = globToRegex(pattern);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
896
|
+
return mcpText(fail(`Invalid pattern: ${msg}`));
|
|
897
|
+
}
|
|
898
|
+
// Try indexed files first (only if searching from project root and filterType includes files)
|
|
899
|
+
let candidates = [];
|
|
900
|
+
let usedIndex = false;
|
|
901
|
+
if (!subPath && (filterType === 'file' || filterType === 'all')) {
|
|
902
|
+
try {
|
|
903
|
+
const indexedFiles = graph.getIndexedFiles();
|
|
904
|
+
if (indexedFiles && indexedFiles.length > 0) {
|
|
905
|
+
usedIndex = true;
|
|
906
|
+
for (const filePath of indexedFiles) {
|
|
907
|
+
candidates.push({
|
|
908
|
+
path: filePath,
|
|
909
|
+
name: basename(filePath),
|
|
910
|
+
type: 'file',
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
// Graph not ready, fall through to filesystem walk
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
// Fallback to filesystem walk if no indexed files or path specified
|
|
920
|
+
if (!usedIndex) {
|
|
921
|
+
walkForFind(searchRoot, filterType, maxDepth, candidates);
|
|
922
|
+
}
|
|
923
|
+
// Filter by pattern
|
|
924
|
+
const matched = [];
|
|
925
|
+
for (const candidate of candidates) {
|
|
926
|
+
if (matchRegex.test(candidate.name)) {
|
|
927
|
+
const relativePath = relative(config.projectRoot, candidate.path).replace(/\\/g, '/');
|
|
928
|
+
const entry = {
|
|
929
|
+
path: candidate.path,
|
|
930
|
+
relativePath,
|
|
931
|
+
name: candidate.name,
|
|
932
|
+
type: candidate.type,
|
|
933
|
+
score: scoreMatch(candidate.name, pattern),
|
|
934
|
+
};
|
|
935
|
+
if (includeSize || includeModified || sortBy === 'size' || sortBy === 'modified') {
|
|
936
|
+
try {
|
|
937
|
+
const st = statSync(candidate.path);
|
|
938
|
+
if (includeSize)
|
|
939
|
+
entry.sizeBytes = st.size;
|
|
940
|
+
if (includeModified)
|
|
941
|
+
entry.modifiedAt = st.mtime.toISOString();
|
|
942
|
+
// Store for sorting even if not included in output
|
|
943
|
+
if (sortBy === 'size' && !includeSize)
|
|
944
|
+
entry.sizeBytes = st.size;
|
|
945
|
+
if (sortBy === 'modified' && !includeModified)
|
|
946
|
+
entry.modifiedAt = st.mtime.toISOString();
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
// Can't stat — skip size/modified
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
matched.push(entry);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
// Sort results
|
|
956
|
+
matched.sort((a, b) => {
|
|
957
|
+
switch (sortBy) {
|
|
958
|
+
case 'name':
|
|
959
|
+
return a.name.localeCompare(b.name);
|
|
960
|
+
case 'size':
|
|
961
|
+
return (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0);
|
|
962
|
+
case 'modified':
|
|
963
|
+
return (b.modifiedAt ?? '').localeCompare(a.modifiedAt ?? '');
|
|
964
|
+
case 'relevance':
|
|
965
|
+
default: {
|
|
966
|
+
if (a.score !== b.score)
|
|
967
|
+
return b.score - a.score;
|
|
968
|
+
return a.name.localeCompare(b.name);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
const truncated = matched.length > maxResults;
|
|
973
|
+
const results = matched.slice(0, maxResults);
|
|
974
|
+
// Clean up internal-only sort fields if not requested
|
|
975
|
+
if (!includeSize) {
|
|
976
|
+
for (const r of results)
|
|
977
|
+
delete r.sizeBytes;
|
|
978
|
+
}
|
|
979
|
+
if (!includeModified) {
|
|
980
|
+
for (const r of results)
|
|
981
|
+
delete r.modifiedAt;
|
|
982
|
+
}
|
|
983
|
+
return mcpText(ok({
|
|
984
|
+
pattern,
|
|
985
|
+
totalResults: matched.length,
|
|
986
|
+
truncated,
|
|
987
|
+
results,
|
|
988
|
+
}, estimator));
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
992
|
+
return mcpText(fail(`Find file failed: ${msg}`));
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
// ── mindmap_batch_read ────────────────────────────────────────
|
|
996
|
+
server.tool('mindmap_batch_read', 'Read multiple file regions in a single call. Eliminates per-call overhead when reading from several files. ' +
|
|
997
|
+
'Each file request specifies a path and optional line range. Returns all results at once.', {
|
|
998
|
+
files: z.array(z.object({
|
|
999
|
+
path: z.string().describe('Absolute or relative file path'),
|
|
1000
|
+
startLine: z.number().int().min(1).default(1).describe('First line to read (1-indexed)'),
|
|
1001
|
+
endLine: z.number().int().min(1).optional().describe('Last line to read (1-indexed). Omit to read 100 lines from startLine'),
|
|
1002
|
+
label: z.string().optional().describe('Optional label/tag for this read (for AI to reference)'),
|
|
1003
|
+
})).min(1).max(20).describe('Array of file read requests (max 20)'),
|
|
1004
|
+
includeContext: z.boolean().default(true).describe('If true and graph is indexed, include containing symbol info'),
|
|
1005
|
+
}, async ({ files: fileRequests, includeContext }) => {
|
|
1006
|
+
const MAX_BATCH_FILES = 20;
|
|
1007
|
+
const MAX_LINES_PER_READ = 300;
|
|
1008
|
+
const DEFAULT_BATCH_WINDOW = 100;
|
|
1009
|
+
const capped = fileRequests.slice(0, MAX_BATCH_FILES);
|
|
1010
|
+
const results = [];
|
|
1011
|
+
for (const req of capped) {
|
|
1012
|
+
try {
|
|
1013
|
+
// Resolve path: if not absolute, resolve against projectRoot
|
|
1014
|
+
let resolved;
|
|
1015
|
+
if (isAbsolute(req.path)) {
|
|
1016
|
+
resolved = resolve(req.path);
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
resolved = resolvePath(req.path, config.projectRoot, false);
|
|
1020
|
+
}
|
|
1021
|
+
if (!resolved) {
|
|
1022
|
+
results.push({
|
|
1023
|
+
path: req.path,
|
|
1024
|
+
label: req.label,
|
|
1025
|
+
startLine: req.startLine,
|
|
1026
|
+
endLine: 0,
|
|
1027
|
+
totalLines: 0,
|
|
1028
|
+
lines: [],
|
|
1029
|
+
error: `Path "${req.path}" resolves outside the project root`,
|
|
1030
|
+
});
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
if (!existsSync(resolved)) {
|
|
1034
|
+
results.push({
|
|
1035
|
+
path: req.path,
|
|
1036
|
+
label: req.label,
|
|
1037
|
+
startLine: req.startLine,
|
|
1038
|
+
endLine: 0,
|
|
1039
|
+
totalLines: 0,
|
|
1040
|
+
lines: [],
|
|
1041
|
+
error: `File not found: ${resolved}`,
|
|
1042
|
+
});
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const content = readFileSync(resolved, 'utf-8');
|
|
1046
|
+
const allLines = content.split('\n');
|
|
1047
|
+
const totalLines = allLines.length;
|
|
1048
|
+
// Normalise line range (1-indexed)
|
|
1049
|
+
const effectiveStart = Math.max(1, req.startLine);
|
|
1050
|
+
let effectiveEnd;
|
|
1051
|
+
if (req.endLine !== undefined) {
|
|
1052
|
+
effectiveEnd = Math.min(req.endLine, totalLines);
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
effectiveEnd = Math.min(effectiveStart + DEFAULT_BATCH_WINDOW - 1, totalLines);
|
|
1056
|
+
}
|
|
1057
|
+
// Cap at MAX_LINES_PER_READ
|
|
1058
|
+
if (effectiveEnd - effectiveStart + 1 > MAX_LINES_PER_READ) {
|
|
1059
|
+
effectiveEnd = effectiveStart + MAX_LINES_PER_READ - 1;
|
|
1060
|
+
}
|
|
1061
|
+
const lines = allLines.slice(effectiveStart - 1, effectiveEnd);
|
|
1062
|
+
// Graph context: find containing symbol
|
|
1063
|
+
let containingSymbol;
|
|
1064
|
+
if (includeContext && graph) {
|
|
1065
|
+
try {
|
|
1066
|
+
const nodes = graph.getFileStructure(resolved);
|
|
1067
|
+
if (nodes && nodes.length > 0) {
|
|
1068
|
+
let bestMatch = null;
|
|
1069
|
+
let bestRange = Infinity;
|
|
1070
|
+
for (const node of nodes) {
|
|
1071
|
+
if (node.type !== 'file' &&
|
|
1072
|
+
node.startLine <= effectiveStart &&
|
|
1073
|
+
node.endLine >= effectiveStart) {
|
|
1074
|
+
const range = node.endLine - node.startLine;
|
|
1075
|
+
if (range < bestRange) {
|
|
1076
|
+
bestRange = range;
|
|
1077
|
+
bestMatch = node;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (bestMatch) {
|
|
1082
|
+
containingSymbol = {
|
|
1083
|
+
name: bestMatch.name,
|
|
1084
|
+
type: bestMatch.type,
|
|
1085
|
+
signature: bestMatch.signature,
|
|
1086
|
+
startLine: bestMatch.startLine,
|
|
1087
|
+
endLine: bestMatch.endLine,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
// Graph not indexed yet or file not in graph — that's fine
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
results.push({
|
|
1097
|
+
path: resolved,
|
|
1098
|
+
label: req.label,
|
|
1099
|
+
startLine: effectiveStart,
|
|
1100
|
+
endLine: effectiveEnd,
|
|
1101
|
+
totalLines,
|
|
1102
|
+
lines,
|
|
1103
|
+
...(containingSymbol ? { containingSymbol } : {}),
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
catch (err) {
|
|
1107
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1108
|
+
results.push({
|
|
1109
|
+
path: req.path,
|
|
1110
|
+
label: req.label,
|
|
1111
|
+
startLine: req.startLine,
|
|
1112
|
+
endLine: 0,
|
|
1113
|
+
totalLines: 0,
|
|
1114
|
+
lines: [],
|
|
1115
|
+
error: `Failed to read: ${msg}`,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return mcpText(ok({ totalFiles: results.length, results }, estimator));
|
|
1120
|
+
});
|
|
631
1121
|
}
|
|
632
1122
|
//# sourceMappingURL=filesystem-tools.js.map
|