@ssmeo/mcp-local-rag 0.18.6

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.
Files changed (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.de.md +441 -0
  3. package/README.es.md +441 -0
  4. package/README.fr.md +441 -0
  5. package/README.md +530 -0
  6. package/README.pt-BR.md +441 -0
  7. package/README.zh-CN.md +441 -0
  8. package/dist/bin/install-skills.d.ts +19 -0
  9. package/dist/bin/install-skills.d.ts.map +1 -0
  10. package/dist/bin/install-skills.js +218 -0
  11. package/dist/bin/install-skills.js.map +1 -0
  12. package/dist/chunker/index.d.ts +24 -0
  13. package/dist/chunker/index.d.ts.map +1 -0
  14. package/dist/chunker/index.js +2 -0
  15. package/dist/chunker/index.js.map +1 -0
  16. package/dist/chunker/semantic-chunker.d.ts +96 -0
  17. package/dist/chunker/semantic-chunker.d.ts.map +1 -0
  18. package/dist/chunker/semantic-chunker.js +362 -0
  19. package/dist/chunker/semantic-chunker.js.map +1 -0
  20. package/dist/chunker/sentence-splitter.d.ts +32 -0
  21. package/dist/chunker/sentence-splitter.d.ts.map +1 -0
  22. package/dist/chunker/sentence-splitter.js +231 -0
  23. package/dist/chunker/sentence-splitter.js.map +1 -0
  24. package/dist/chunker/token-containment.d.ts +39 -0
  25. package/dist/chunker/token-containment.d.ts.map +1 -0
  26. package/dist/chunker/token-containment.js +83 -0
  27. package/dist/chunker/token-containment.js.map +1 -0
  28. package/dist/cli/common.d.ts +34 -0
  29. package/dist/cli/common.d.ts.map +1 -0
  30. package/dist/cli/common.js +121 -0
  31. package/dist/cli/common.js.map +1 -0
  32. package/dist/cli/delete.d.ts +3 -0
  33. package/dist/cli/delete.d.ts.map +1 -0
  34. package/dist/cli/delete.js +169 -0
  35. package/dist/cli/delete.js.map +1 -0
  36. package/dist/cli/file-collection.d.ts +2 -0
  37. package/dist/cli/file-collection.d.ts.map +1 -0
  38. package/dist/cli/file-collection.js +53 -0
  39. package/dist/cli/file-collection.js.map +1 -0
  40. package/dist/cli/ingest.d.ts +69 -0
  41. package/dist/cli/ingest.d.ts.map +1 -0
  42. package/dist/cli/ingest.js +323 -0
  43. package/dist/cli/ingest.js.map +1 -0
  44. package/dist/cli/list.d.ts +27 -0
  45. package/dist/cli/list.d.ts.map +1 -0
  46. package/dist/cli/list.js +207 -0
  47. package/dist/cli/list.js.map +1 -0
  48. package/dist/cli/options.d.ts +89 -0
  49. package/dist/cli/options.d.ts.map +1 -0
  50. package/dist/cli/options.js +244 -0
  51. package/dist/cli/options.js.map +1 -0
  52. package/dist/cli/query.d.ts +17 -0
  53. package/dist/cli/query.d.ts.map +1 -0
  54. package/dist/cli/query.js +194 -0
  55. package/dist/cli/query.js.map +1 -0
  56. package/dist/cli/read-neighbors.d.ts +3 -0
  57. package/dist/cli/read-neighbors.d.ts.map +1 -0
  58. package/dist/cli/read-neighbors.js +192 -0
  59. package/dist/cli/read-neighbors.js.map +1 -0
  60. package/dist/cli/status.d.ts +6 -0
  61. package/dist/cli/status.d.ts.map +1 -0
  62. package/dist/cli/status.js +78 -0
  63. package/dist/cli/status.js.map +1 -0
  64. package/dist/cli/sync.d.ts +6 -0
  65. package/dist/cli/sync.d.ts.map +1 -0
  66. package/dist/cli/sync.js +271 -0
  67. package/dist/cli/sync.js.map +1 -0
  68. package/dist/cli-main.d.ts +9 -0
  69. package/dist/cli-main.d.ts.map +1 -0
  70. package/dist/cli-main.js +60 -0
  71. package/dist/cli-main.js.map +1 -0
  72. package/dist/embedder/index.d.ts +143 -0
  73. package/dist/embedder/index.d.ts.map +1 -0
  74. package/dist/embedder/index.js +429 -0
  75. package/dist/embedder/index.js.map +1 -0
  76. package/dist/embedder/remote.d.ts +51 -0
  77. package/dist/embedder/remote.d.ts.map +1 -0
  78. package/dist/embedder/remote.js +182 -0
  79. package/dist/embedder/remote.js.map +1 -0
  80. package/dist/features/list.d.ts +37 -0
  81. package/dist/features/list.d.ts.map +1 -0
  82. package/dist/features/list.js +41 -0
  83. package/dist/features/list.js.map +1 -0
  84. package/dist/features/sync.d.ts +219 -0
  85. package/dist/features/sync.d.ts.map +1 -0
  86. package/dist/features/sync.js +461 -0
  87. package/dist/features/sync.js.map +1 -0
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +56 -0
  91. package/dist/index.js.map +1 -0
  92. package/dist/ingest/compute.d.ts +63 -0
  93. package/dist/ingest/compute.d.ts.map +1 -0
  94. package/dist/ingest/compute.js +161 -0
  95. package/dist/ingest/compute.js.map +1 -0
  96. package/dist/ingest/file.d.ts +40 -0
  97. package/dist/ingest/file.d.ts.map +1 -0
  98. package/dist/ingest/file.js +74 -0
  99. package/dist/ingest/file.js.map +1 -0
  100. package/dist/ingest/visual.d.ts +52 -0
  101. package/dist/ingest/visual.d.ts.map +1 -0
  102. package/dist/ingest/visual.js +294 -0
  103. package/dist/ingest/visual.js.map +1 -0
  104. package/dist/parser/docx-parser.d.ts +12 -0
  105. package/dist/parser/docx-parser.d.ts.map +1 -0
  106. package/dist/parser/docx-parser.js +375 -0
  107. package/dist/parser/docx-parser.js.map +1 -0
  108. package/dist/parser/html-parser.d.ts +16 -0
  109. package/dist/parser/html-parser.d.ts.map +1 -0
  110. package/dist/parser/html-parser.js +119 -0
  111. package/dist/parser/html-parser.js.map +1 -0
  112. package/dist/parser/index.d.ts +125 -0
  113. package/dist/parser/index.d.ts.map +1 -0
  114. package/dist/parser/index.js +364 -0
  115. package/dist/parser/index.js.map +1 -0
  116. package/dist/parser/pdf-extract.d.ts +49 -0
  117. package/dist/parser/pdf-extract.d.ts.map +1 -0
  118. package/dist/parser/pdf-extract.js +114 -0
  119. package/dist/parser/pdf-extract.js.map +1 -0
  120. package/dist/parser/pdf-filter.d.ts +91 -0
  121. package/dist/parser/pdf-filter.d.ts.map +1 -0
  122. package/dist/parser/pdf-filter.js +522 -0
  123. package/dist/parser/pdf-filter.js.map +1 -0
  124. package/dist/parser/title-extractor.d.ts +28 -0
  125. package/dist/parser/title-extractor.d.ts.map +1 -0
  126. package/dist/parser/title-extractor.js +105 -0
  127. package/dist/parser/title-extractor.js.map +1 -0
  128. package/dist/pdf-visual/captioner.d.ts +11 -0
  129. package/dist/pdf-visual/captioner.d.ts.map +1 -0
  130. package/dist/pdf-visual/captioner.js +48 -0
  131. package/dist/pdf-visual/captioner.js.map +1 -0
  132. package/dist/pdf-visual/captioners/fast.d.ts +7 -0
  133. package/dist/pdf-visual/captioners/fast.d.ts.map +1 -0
  134. package/dist/pdf-visual/captioners/fast.js +108 -0
  135. package/dist/pdf-visual/captioners/fast.js.map +1 -0
  136. package/dist/pdf-visual/captioners/quality.d.ts +7 -0
  137. package/dist/pdf-visual/captioners/quality.d.ts.map +1 -0
  138. package/dist/pdf-visual/captioners/quality.js +101 -0
  139. package/dist/pdf-visual/captioners/quality.js.map +1 -0
  140. package/dist/pdf-visual/captioners/shared.d.ts +81 -0
  141. package/dist/pdf-visual/captioners/shared.d.ts.map +1 -0
  142. package/dist/pdf-visual/captioners/shared.js +156 -0
  143. package/dist/pdf-visual/captioners/shared.js.map +1 -0
  144. package/dist/pdf-visual/detector.d.ts +9 -0
  145. package/dist/pdf-visual/detector.d.ts.map +1 -0
  146. package/dist/pdf-visual/detector.js +269 -0
  147. package/dist/pdf-visual/detector.js.map +1 -0
  148. package/dist/pdf-visual/index.d.ts +13 -0
  149. package/dist/pdf-visual/index.d.ts.map +1 -0
  150. package/dist/pdf-visual/index.js +53 -0
  151. package/dist/pdf-visual/index.js.map +1 -0
  152. package/dist/pdf-visual/renderer.d.ts +9 -0
  153. package/dist/pdf-visual/renderer.d.ts.map +1 -0
  154. package/dist/pdf-visual/renderer.js +186 -0
  155. package/dist/pdf-visual/renderer.js.map +1 -0
  156. package/dist/pdf-visual/types.d.ts +53 -0
  157. package/dist/pdf-visual/types.d.ts.map +1 -0
  158. package/dist/pdf-visual/types.js +17 -0
  159. package/dist/pdf-visual/types.js.map +1 -0
  160. package/dist/server/error-utils.d.ts +63 -0
  161. package/dist/server/error-utils.d.ts.map +1 -0
  162. package/dist/server/error-utils.js +124 -0
  163. package/dist/server/error-utils.js.map +1 -0
  164. package/dist/server/index.d.ts +216 -0
  165. package/dist/server/index.d.ts.map +1 -0
  166. package/dist/server/index.js +1068 -0
  167. package/dist/server/index.js.map +1 -0
  168. package/dist/server/list-scanner.d.ts +30 -0
  169. package/dist/server/list-scanner.d.ts.map +1 -0
  170. package/dist/server/list-scanner.js +50 -0
  171. package/dist/server/list-scanner.js.map +1 -0
  172. package/dist/server/tool-definitions.d.ts +8 -0
  173. package/dist/server/tool-definitions.d.ts.map +1 -0
  174. package/dist/server/tool-definitions.js +181 -0
  175. package/dist/server/tool-definitions.js.map +1 -0
  176. package/dist/server/tool-input.d.ts +37 -0
  177. package/dist/server/tool-input.d.ts.map +1 -0
  178. package/dist/server/tool-input.js +211 -0
  179. package/dist/server/tool-input.js.map +1 -0
  180. package/dist/server/types.d.ts +307 -0
  181. package/dist/server/types.d.ts.map +1 -0
  182. package/dist/server/types.js +3 -0
  183. package/dist/server/types.js.map +1 -0
  184. package/dist/server-main.d.ts +40 -0
  185. package/dist/server-main.d.ts.map +1 -0
  186. package/dist/server-main.js +254 -0
  187. package/dist/server-main.js.map +1 -0
  188. package/dist/utils/base-dirs.d.ts +145 -0
  189. package/dist/utils/base-dirs.d.ts.map +1 -0
  190. package/dist/utils/base-dirs.js +344 -0
  191. package/dist/utils/base-dirs.js.map +1 -0
  192. package/dist/utils/errors.d.ts +32 -0
  193. package/dist/utils/errors.d.ts.map +1 -0
  194. package/dist/utils/errors.js +61 -0
  195. package/dist/utils/errors.js.map +1 -0
  196. package/dist/utils/limits.d.ts +26 -0
  197. package/dist/utils/limits.d.ts.map +1 -0
  198. package/dist/utils/limits.js +28 -0
  199. package/dist/utils/limits.js.map +1 -0
  200. package/dist/utils/list-sources.d.ts +38 -0
  201. package/dist/utils/list-sources.d.ts.map +1 -0
  202. package/dist/utils/list-sources.js +40 -0
  203. package/dist/utils/list-sources.js.map +1 -0
  204. package/dist/utils/raw-data-utils.d.ts +76 -0
  205. package/dist/utils/raw-data-utils.d.ts.map +1 -0
  206. package/dist/utils/raw-data-utils.js +202 -0
  207. package/dist/utils/raw-data-utils.js.map +1 -0
  208. package/dist/utils/scan.d.ts +114 -0
  209. package/dist/utils/scan.d.ts.map +1 -0
  210. package/dist/utils/scan.js +209 -0
  211. package/dist/utils/scan.js.map +1 -0
  212. package/dist/utils/scope-match.d.ts +38 -0
  213. package/dist/utils/scope-match.d.ts.map +1 -0
  214. package/dist/utils/scope-match.js +86 -0
  215. package/dist/utils/scope-match.js.map +1 -0
  216. package/dist/utils/sensitive-path.d.ts +9 -0
  217. package/dist/utils/sensitive-path.d.ts.map +1 -0
  218. package/dist/utils/sensitive-path.js +78 -0
  219. package/dist/utils/sensitive-path.js.map +1 -0
  220. package/dist/utils/sync-path-key.d.ts +14 -0
  221. package/dist/utils/sync-path-key.d.ts.map +1 -0
  222. package/dist/utils/sync-path-key.js +27 -0
  223. package/dist/utils/sync-path-key.js.map +1 -0
  224. package/dist/utils/type-guards.d.ts +18 -0
  225. package/dist/utils/type-guards.d.ts.map +1 -0
  226. package/dist/utils/type-guards.js +34 -0
  227. package/dist/utils/type-guards.js.map +1 -0
  228. package/dist/utils/visual-profile.d.ts +10 -0
  229. package/dist/utils/visual-profile.d.ts.map +1 -0
  230. package/dist/utils/visual-profile.js +15 -0
  231. package/dist/utils/visual-profile.js.map +1 -0
  232. package/dist/vectordb/index.d.ts +132 -0
  233. package/dist/vectordb/index.d.ts.map +1 -0
  234. package/dist/vectordb/index.js +617 -0
  235. package/dist/vectordb/index.js.map +1 -0
  236. package/dist/vectordb/search-filters.d.ts +15 -0
  237. package/dist/vectordb/search-filters.d.ts.map +1 -0
  238. package/dist/vectordb/search-filters.js +129 -0
  239. package/dist/vectordb/search-filters.js.map +1 -0
  240. package/dist/vectordb/types.d.ts +194 -0
  241. package/dist/vectordb/types.d.ts.map +1 -0
  242. package/dist/vectordb/types.js +237 -0
  243. package/dist/vectordb/types.js.map +1 -0
  244. package/package.json +108 -0
  245. package/skills/mcp-local-rag/SKILL.md +234 -0
  246. package/skills/mcp-local-rag/references/cli-reference.md +190 -0
  247. package/skills/mcp-local-rag/references/html-ingestion.md +78 -0
@@ -0,0 +1,1068 @@
1
+ // RAGServer implementation with MCP tools
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import { resolve, sep } from 'node:path';
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
9
+ import { DEFAULT_MIN_CHUNK_LENGTH, SemanticChunker } from '../chunker/index.js';
10
+ import { Embedder } from '../embedder/index.js';
11
+ import { RemoteEmbedder } from '../embedder/remote.js';
12
+ import { listDocuments } from '../features/list.js';
13
+ import { formatSyncError, runSync, } from '../features/sync.js';
14
+ import { buildChunksAndEmbeddings, buildVectorChunks, computeContentHash, } from '../ingest/compute.js';
15
+ import { buildPreparedFileVectorChunks, prepareFileForIngest, } from '../ingest/file.js';
16
+ import { parseHtml } from '../parser/html-parser.js';
17
+ import { DocumentParser, ValidationError } from '../parser/index.js';
18
+ import { extractMarkdownTitle, extractTxtTitle } from '../parser/title-extractor.js';
19
+ import { displayPath } from '../utils/base-dirs.js';
20
+ import { toError } from '../utils/errors.js';
21
+ import { MAX_SCAN_DEPTH } from '../utils/limits.js';
22
+ import { checkRawDataArtifacts, extractSourceFromPath, generateMetaJsonPath, generateRawDataPath, isEnoent, isManagedRawDataPath, isPathInRawDataDir, isPathInRawDataDirLexical, loadMetaJson, saveMetaJson, saveRawData, } from '../utils/raw-data-utils.js';
23
+ import { bfsCollectSupportedFiles, canonicalizeRequestedPath, classifyRequestedPath, } from '../utils/scan.js';
24
+ import { nonAbsolutePrefixes } from '../utils/scope-match.js';
25
+ import { isRecord } from '../utils/type-guards.js';
26
+ import { VectorStore } from '../vectordb/index.js';
27
+ import { DatabaseError } from '../vectordb/types.js';
28
+ import { appendConfigWarnings, buildConfigErrorBlock, formatErrorForClient, logError, toMcpError, } from './error-utils.js';
29
+ import { normalizeBaseDirs, scanBaseDir } from './list-scanner.js';
30
+ import { toolDefinitions } from './tool-definitions.js';
31
+ import { parseDeleteFileInput, parseIngestDataInput, parseIngestFileInput, parseListFilesInput, parseQueryDocumentsInput, parseReadChunkNeighborsInput, parseSyncStartInput, parseSyncStatusInput, } from './tool-input.js';
32
+ /**
33
+ * Per-tool client-message policy for `toMcpError`. A `prefix` is prepended
34
+ * only to native / non-`AppError` failures; a recognized `AppError` keeps its
35
+ * own message.
36
+ */
37
+ const TOOL_ERROR_CONTEXT = {
38
+ ingest_file: { prefix: 'Failed to ingest file' },
39
+ ingest_data: { prefix: 'Failed to ingest data' },
40
+ delete_file: { prefix: 'Failed to delete file' },
41
+ read_chunk_neighbors: { prefix: 'Failed to read chunk neighbors' },
42
+ sync_start: { prefix: 'Failed to start sync' },
43
+ query_documents: {},
44
+ list_files: {},
45
+ status: {},
46
+ sync_status: {},
47
+ };
48
+ const ATTACHMENT_WARNING_ANNOTATIONS = {
49
+ audience: ['user', 'assistant'],
50
+ priority: 0.3,
51
+ };
52
+ /** Wrap a parser-normalized scalar-or-array input into an array. */
53
+ function toArray(value) {
54
+ return Array.isArray(value) ? value : [value];
55
+ }
56
+ function attachmentOmissionWarning(omittedCount) {
57
+ return {
58
+ type: 'text',
59
+ text: `Warning: Visual attachments omitted ${omittedCount} unavailable or invalid attachment${omittedCount === 1 ? '' : 's'}. Text search results are unchanged.`,
60
+ annotations: ATTACHMENT_WARNING_ANNOTATIONS,
61
+ };
62
+ }
63
+ function attachmentHydrationFailureWarning() {
64
+ return {
65
+ type: 'text',
66
+ text: 'Warning: Visual attachments could not be loaded. Text search results are unchanged.',
67
+ annotations: ATTACHMENT_WARNING_ANNOTATIONS,
68
+ };
69
+ }
70
+ /**
71
+ * Tools that mutate the index and therefore pass through the one server-instance
72
+ * mutation guard (SYNC-007). Read-only tools are deliberately absent: they stay
73
+ * callable while a sync holds the guard.
74
+ */
75
+ const MUTATION_TOOLS = new Set([
76
+ 'sync_start',
77
+ 'ingest_file',
78
+ 'ingest_data',
79
+ 'delete_file',
80
+ ]);
81
+ const packageManifest = createRequire(import.meta.url)('../../package.json');
82
+ const packageVersion = isRecord(packageManifest) && typeof packageManifest['version'] === 'string'
83
+ ? packageManifest['version']
84
+ : '0.0.0';
85
+ /**
86
+ * Zero-chunk outcome of {@link RAGServer.handleIngestFile}, raised before any
87
+ * destructive work so the existing index survives.
88
+ *
89
+ * An `McpError` subclass so the client sees an unchanged code and message,
90
+ * while sync can count it as `empty` rather than as a failed job.
91
+ */
92
+ class NoChunksError extends McpError {
93
+ }
94
+ /**
95
+ * Render the scanner's coverage facts as warnings — each unobserved region is
96
+ * a reason prune was withheld there. The wording is not a contract.
97
+ *
98
+ * Paths go through `displayPath`, as `list_files` does: the MCP client is
99
+ * remote to the operator's account, so `~` hides the OS username. The CLI
100
+ * variant prints full paths, since that terminal belongs to the operator.
101
+ */
102
+ function coverageWarnings(coverage, maxFileSize) {
103
+ return [
104
+ ...coverage.unreadableDirs.map(({ dirPath, code }) => `Warning: cannot read directory (${code}), so its indexed files were kept: ${displayPath(dirPath)}`),
105
+ ...coverage.depthLimitedDirs.map((dirPath) => `Warning: not scanned because it exceeds the maximum depth (${MAX_SCAN_DEPTH}), so its indexed files were kept: ${displayPath(dirPath)}`),
106
+ ...coverage.skippedSymlinks.map((linkPath) => `Warning: symbolic link not followed, so its indexed files were kept: ${displayPath(linkPath)}`),
107
+ ...coverage.oversizedFiles.map((filePath) => `Warning: not read because it exceeds the maximum file size (${maxFileSize} bytes), so its indexed chunks were kept: ${displayPath(filePath)}`),
108
+ ];
109
+ }
110
+ /** RAG server compliant with MCP Protocol */
111
+ /** Formats whose embedded images this server can extract. */
112
+ function supportsEmbeddedImages(filePath) {
113
+ const lower = filePath.toLowerCase();
114
+ return lower.endsWith('.pdf') || lower.endsWith('.docx');
115
+ }
116
+ export class RAGServer {
117
+ server;
118
+ vectorStore;
119
+ embedder;
120
+ chunker;
121
+ parser;
122
+ dbPath;
123
+ /**
124
+ * Allowed document roots, REALPATH-normalized — the security boundary handed
125
+ * to `DocumentParser`. `list_files` scanning and display use the normal-path
126
+ * `rawBaseDirs` below instead.
127
+ */
128
+ baseDirs;
129
+ /**
130
+ * Normal-path (resolve()) roots, index-aligned with `baseDirs`, for
131
+ * user-facing `list_files` scan/display. Falls back to `baseDirs` for legacy
132
+ * `{ baseDir }` callers. See {@link BaseDirsConfig} for the path policy.
133
+ */
134
+ rawBaseDirs;
135
+ /** Legacy single-root accessor for `rawBaseDirs`. Derived from `rawBaseDirs[0]`. */
136
+ rawBaseDir;
137
+ cacheDir;
138
+ // Used by handleListFiles filter to exclude system-managed directories
139
+ excludePaths;
140
+ configWarnings;
141
+ /**
142
+ * When non-null the server is in degraded mode: `status` stays callable so the
143
+ * user can diagnose over MCP, while root-dependent tools must reject first.
144
+ */
145
+ configError;
146
+ minChunkLength;
147
+ /**
148
+ * Configured byte ceiling for one ingested file. The parser enforces it for
149
+ * parsing; sync also needs it before hashing, where nothing else bounds the
150
+ * read.
151
+ */
152
+ maxFileSize;
153
+ device;
154
+ storeImages;
155
+ /**
156
+ * The one current-or-latest sync job this process retains. A new `sync_start`
157
+ * replaces a terminal record, so the older id becomes unknown; there is no
158
+ * history, persistence or recovery.
159
+ */
160
+ syncJob = null;
161
+ /**
162
+ * True while one external mutation is in flight (SYNC-007). A request-scoped
163
+ * mutation clears it when the request completes; a sync keeps it until its
164
+ * job reaches a terminal state.
165
+ */
166
+ mutationInFlight = false;
167
+ constructor(config) {
168
+ this.dbPath = config.dbPath;
169
+ // Normalize both config shapes into a single `baseDirs: string[]` plus the
170
+ // legacy single-root accessor. See `normalizeBaseDirs` for the degraded-
171
+ // mode and misuse semantics.
172
+ const { baseDirs, baseDir } = normalizeBaseDirs(config);
173
+ this.baseDirs = baseDirs;
174
+ // Normal-path roots for user-facing scanning; fall back to the realpath'd
175
+ // roots for legacy `{ baseDir }` callers.
176
+ const rawBaseDirs = config.rawBaseDirs !== undefined ? [...config.rawBaseDirs] : [...baseDirs];
177
+ this.rawBaseDirs = rawBaseDirs;
178
+ this.rawBaseDir = rawBaseDirs[0] ?? baseDir;
179
+ this.cacheDir = config.cacheDir;
180
+ this.configWarnings = config.configWarnings ?? [];
181
+ this.configError = config.configError ?? null;
182
+ this.minChunkLength = config.chunkMinLength ?? DEFAULT_MIN_CHUNK_LENGTH;
183
+ this.maxFileSize = config.maxFileSize;
184
+ this.device = config.device;
185
+ this.storeImages = config.storeImages ?? false;
186
+ this.excludePaths = [`${resolve(this.dbPath)}${sep}`, `${resolve(this.cacheDir)}${sep}`];
187
+ this.server = new Server({ name: 'rag-mcp-server', version: packageVersion }, { capabilities: { tools: {} } });
188
+ // Component initialization
189
+ // Only pass quality filter settings if they are defined
190
+ const vectorStoreConfig = {
191
+ dbPath: config.dbPath,
192
+ tableName: 'chunks',
193
+ };
194
+ if (config.maxDistance !== undefined) {
195
+ vectorStoreConfig.maxDistance = config.maxDistance;
196
+ }
197
+ if (config.grouping !== undefined) {
198
+ vectorStoreConfig.grouping = config.grouping;
199
+ }
200
+ if (config.hybridWeight !== undefined) {
201
+ vectorStoreConfig.hybridWeight = config.hybridWeight;
202
+ }
203
+ if (config.maxFiles !== undefined) {
204
+ vectorStoreConfig.maxFiles = config.maxFiles;
205
+ }
206
+ this.vectorStore = new VectorStore(vectorStoreConfig);
207
+ const embedderConfig = {
208
+ modelPath: config.modelName,
209
+ batchSize: 16,
210
+ cacheDir: config.cacheDir,
211
+ };
212
+ if (config.device !== undefined) {
213
+ embedderConfig.device = config.device;
214
+ }
215
+ if (config.dtype !== undefined) {
216
+ embedderConfig.dtype = config.dtype;
217
+ }
218
+ const embedder = config.embeddingServerUrl !== undefined
219
+ ? new RemoteEmbedder({ serverUrl: config.embeddingServerUrl, batchSize: 16 })
220
+ : new Embedder(embedderConfig);
221
+ this.embedder = embedder;
222
+ this.chunker = new SemanticChunker(config.chunkMinLength !== undefined ? { minChunkLength: config.chunkMinLength } : {});
223
+ // Always construct the parser with the multi-root shape — the parser
224
+ // accepts a single-element `baseDirs` array as the byte-equivalent of
225
+ // the legacy `baseDir` shape, so passing `this.baseDirs` covers both
226
+ // config inputs without branching here.
227
+ this.parser = new DocumentParser({
228
+ baseDirs: this.baseDirs,
229
+ maxFileSize: config.maxFileSize,
230
+ });
231
+ this.setupHandlers();
232
+ }
233
+ /**
234
+ * Fail-fast guard for root-dependent tools in degraded mode: reject before any
235
+ * DB / embedder / parser access. Throws the stored error so the dispatcher
236
+ * mapper owns the code, rather than hand-building one here.
237
+ *
238
+ * `status` deliberately does not call this — it stays callable and reports the
239
+ * error in a diagnostic block, so recovery does not need stderr.
240
+ */
241
+ assertConfigOk() {
242
+ if (this.configError !== null) {
243
+ throw this.configError;
244
+ }
245
+ }
246
+ /** Every handler funnels through here, so the warning shape lives in one place. */
247
+ withWarnings(content) {
248
+ return appendConfigWarnings(content, this.configWarnings);
249
+ }
250
+ /**
251
+ * Take the single external-mutation slot, or describe the overlap.
252
+ *
253
+ * Returns `null` when the slot was free. Otherwise an ordinary result with
254
+ * `isError: true` rather than a thrown error, naming the holding job id and
255
+ * pointing at `sync_status` — the only way to learn when to retry.
256
+ */
257
+ acquireMutation() {
258
+ if (!this.mutationInFlight) {
259
+ this.mutationInFlight = true;
260
+ return null;
261
+ }
262
+ const runningJob = this.syncJob?.state === 'running' ? this.syncJob : null;
263
+ const text = runningJob === null
264
+ ? 'Another write operation is already running on this server. Retry when it finishes.'
265
+ : `A sync job is running (jobId: ${runningJob.jobId}). Poll sync_status with that jobId and retry once it is no longer running.`;
266
+ return { content: this.withWarnings([{ type: 'text', text }]), isError: true };
267
+ }
268
+ releaseMutation() {
269
+ this.mutationInFlight = false;
270
+ }
271
+ /**
272
+ * Set up MCP handlers
273
+ */
274
+ setupHandlers() {
275
+ // Tool list
276
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
277
+ tools: toolDefinitions,
278
+ }));
279
+ // The handlers carry no error mapping: every error reaches the single catch
280
+ // below with its ORIGINAL identity, which logs the full cause chain to
281
+ // stderr and maps it for the client. `TOOL_ERROR_CONTEXT` holds each
282
+ // handler's message policy, so that table lives in one place.
283
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
284
+ const toolName = request.params.name;
285
+ // The mutation guard sits here, on the external dispatch path only, so
286
+ // an internal call such as `handleIngestData` -> `handleIngestFile`
287
+ // cannot reacquire it and self-deadlock.
288
+ if (MUTATION_TOOLS.has(toolName)) {
289
+ const overlap = this.acquireMutation();
290
+ if (overlap !== null) {
291
+ return overlap;
292
+ }
293
+ }
294
+ // `sync_start` hands the guard to the job it schedules, which releases
295
+ // it on the terminal transition; every other mutation is request-scoped
296
+ // and releases below whether it succeeds or throws.
297
+ let releaseWhenRequestEnds = MUTATION_TOOLS.has(toolName);
298
+ try {
299
+ switch (toolName) {
300
+ case 'query_documents':
301
+ return await this.handleQueryDocuments(parseQueryDocumentsInput(request.params.arguments));
302
+ case 'ingest_file':
303
+ return await this.handleIngestFile(request.params.arguments);
304
+ case 'ingest_data':
305
+ return await this.handleIngestData(parseIngestDataInput(request.params.arguments));
306
+ case 'delete_file':
307
+ return await this.handleDeleteFile(request.params.arguments);
308
+ case 'read_chunk_neighbors':
309
+ return await this.handleReadChunkNeighbors(request.params.arguments);
310
+ case 'list_files':
311
+ return await this.handleListFiles(parseListFilesInput(request.params.arguments));
312
+ case 'status':
313
+ return await this.handleStatus();
314
+ case 'sync_start': {
315
+ const started = await this.handleSyncStart(parseSyncStartInput(request.params.arguments));
316
+ // Reached only once a job is registered and scheduled; a throw
317
+ // above leaves the flag set so the `finally` frees the guard.
318
+ releaseWhenRequestEnds = false;
319
+ return started;
320
+ }
321
+ case 'sync_status':
322
+ return await this.handleSyncStatus(parseSyncStatusInput(request.params.arguments));
323
+ default:
324
+ throw new Error(`Unknown tool: ${toolName}`);
325
+ }
326
+ }
327
+ catch (error) {
328
+ const context = TOOL_ERROR_CONTEXT[toolName] ?? {};
329
+ logError(toolName, error);
330
+ throw toMcpError(error, context);
331
+ }
332
+ finally {
333
+ if (releaseWhenRequestEnds) {
334
+ this.releaseMutation();
335
+ }
336
+ }
337
+ });
338
+ }
339
+ /**
340
+ * Initialization
341
+ */
342
+ async initialize() {
343
+ await this.vectorStore.initialize();
344
+ console.error('RAGServer initialized');
345
+ }
346
+ /**
347
+ * query_documents tool handler
348
+ */
349
+ async handleQueryDocuments(args) {
350
+ // query_documents reads only LanceDB, so it stays callable in degraded
351
+ // mode; `withWarnings` and `status` remain the diagnostic surface.
352
+ const queryVector = await this.embedder.embed(args.query);
353
+ // `args.scope` is parser-validated; array-wrap without re-validating, and
354
+ // omit the key when absent (exactOptionalPropertyTypes) to keep the scope-absent path.
355
+ const searchResults = await this.vectorStore.search(queryVector, {
356
+ queryText: args.query,
357
+ limit: args.limit ?? 10,
358
+ ...(args.scope !== undefined ? { scope: toArray(args.scope) } : {}),
359
+ });
360
+ // Format results with source restoration for raw-data files
361
+ const results = searchResults.map((result) => {
362
+ const queryResult = {
363
+ filePath: result.filePath,
364
+ chunkIndex: result.chunkIndex,
365
+ text: result.text,
366
+ score: result.score,
367
+ fileTitle: result.fileTitle ?? null,
368
+ };
369
+ if (isManagedRawDataPath(result.filePath, this.dbPath)) {
370
+ const source = extractSourceFromPath(result.filePath);
371
+ if (source) {
372
+ queryResult.source = source;
373
+ }
374
+ }
375
+ return queryResult;
376
+ });
377
+ let hydratedRows = [];
378
+ let attachmentWarning = null;
379
+ try {
380
+ const hydration = await this.vectorStore.hydrateVisualAttachments(searchResults);
381
+ hydratedRows = hydration.rows;
382
+ if (hydration.omittedCount > 0) {
383
+ attachmentWarning = attachmentOmissionWarning(hydration.omittedCount);
384
+ }
385
+ }
386
+ catch {
387
+ attachmentWarning = attachmentHydrationFailureWarning();
388
+ }
389
+ const content = [
390
+ {
391
+ type: 'text',
392
+ text: JSON.stringify(results, null, 2),
393
+ },
394
+ ];
395
+ const attachmentsByIdentity = new Map(hydratedRows.map((row) => [row.id, row.attachments]));
396
+ for (const [resultIndex, result] of results.entries()) {
397
+ const attachments = attachmentsByIdentity.get(searchResults[resultIndex]?.id ?? '') ?? [];
398
+ for (const attachment of attachments) {
399
+ content.push({
400
+ type: 'text',
401
+ text: JSON.stringify({
402
+ type: 'visual_attachment',
403
+ result: {
404
+ filePath: result.filePath,
405
+ chunkIndex: result.chunkIndex,
406
+ ...(result.source === undefined ? {} : { source: result.source }),
407
+ },
408
+ imageIndex: attachment.imageIndex,
409
+ mimeType: attachment.mimeType,
410
+ }),
411
+ });
412
+ content.push({
413
+ type: 'image',
414
+ data: attachment.data,
415
+ mimeType: attachment.mimeType,
416
+ });
417
+ }
418
+ }
419
+ if (attachmentWarning) {
420
+ content.push(attachmentWarning);
421
+ }
422
+ // Append config warnings on every call because MCP clients may hide
423
+ // stderr and may not retain context across calls.
424
+ return { content: this.withWarnings(content) };
425
+ }
426
+ /**
427
+ * `options.skipOptimize` is internal: sync compacts once per run, so its reuse
428
+ * of this handler must not compact once per file (a 100-file sync would
429
+ * otherwise perform 101 compactions). The tools omit it and compact per call.
430
+ */
431
+ async handleIngestFile(raw, options = {}) {
432
+ const result = await this.ingestFile(parseIngestFileInput(raw), options);
433
+ // Insertion has committed. Maintenance errors must not restore old rows.
434
+ if (options.skipOptimize !== true) {
435
+ await this.vectorStore.optimize();
436
+ }
437
+ return {
438
+ content: this.withWarnings([
439
+ {
440
+ type: 'text',
441
+ text: JSON.stringify(result, null, 2),
442
+ },
443
+ ]),
444
+ };
445
+ }
446
+ /** What one ingest source contributed, before rows are built and stored. */
447
+ async prepareRawDataIngest(filePath) {
448
+ // Raw-data files: skip parser validation, read directly.
449
+ const sourceBytes = await readFile(filePath);
450
+ const text = sourceBytes.toString('utf-8');
451
+ const meta = await loadMetaJson(filePath);
452
+ const title = meta?.title ?? null;
453
+ console.error(`Read raw-data file: ${filePath} (${text.length} characters)`);
454
+ const { chunks, embeddings } = await buildChunksAndEmbeddings(text, this.chunker, this.embedder);
455
+ return {
456
+ title,
457
+ omittedImageCount: 0,
458
+ vectorChunks: buildVectorChunks({
459
+ filePath,
460
+ chunks,
461
+ embeddings,
462
+ fileSize: text.length,
463
+ fileTitle: title,
464
+ contentHash: computeContentHash(sourceBytes),
465
+ }),
466
+ };
467
+ }
468
+ async prepareSourceFileIngest(filePath, options) {
469
+ // The MCP boundary accepts an arbitrary client path, unlike CLI ingestion
470
+ // paths that have already passed a regular-file collector. Reject a FIFO
471
+ // before the shared whole-file hash read can block the mutation slot.
472
+ await this.parser.validateFilePath(filePath);
473
+ this.parser.validateFileSize(filePath);
474
+ if (!(await stat(filePath)).isFile()) {
475
+ throw new ValidationError(`Ingest source is not a regular file: ${filePath}`);
476
+ }
477
+ const preparedFile = await prepareFileForIngest(filePath, { parser: this.parser, chunker: this.chunker, embedder: this.embedder }, options);
478
+ return {
479
+ title: preparedFile.title,
480
+ omittedImageCount: preparedFile.omittedImageCount,
481
+ preparedFile,
482
+ };
483
+ }
484
+ /**
485
+ * Restore the pre-ingest state after a failed insert. A rollback that itself
486
+ * fails is reported as its own error, because the prior data may now be gone.
487
+ */
488
+ async rollbackIngest(filePath, backup, insertError) {
489
+ try {
490
+ // insertChunks can fail during setup after writing rows. Remove that
491
+ // version before restoring the backup, including failed first ingests.
492
+ await this.vectorStore.deleteChunks(filePath);
493
+ if (backup.length > 0) {
494
+ await this.vectorStore.insertChunks(backup);
495
+ await this.vectorStore.optimize();
496
+ }
497
+ console.error(`Rollback completed: ${backup.length} chunks restored`);
498
+ }
499
+ catch (rollbackError) {
500
+ console.error('Rollback failed:', rollbackError);
501
+ throw new DatabaseError(`Ingest failed and rollback failed for ${filePath}; existing data may not have been restored. Original insert error: ${toError(insertError).message}`, { cause: toError(insertError) });
502
+ }
503
+ }
504
+ async ingestFile(args, options = {}) {
505
+ const isRawData = await isPathInRawDataDir(args.filePath, this.dbPath);
506
+ // Skip the configError gate only for paths structurally inside
507
+ // `<dbPath>/raw-data/` (internal invocation from handleIngestData).
508
+ if (!isRawData) {
509
+ this.assertConfigOk();
510
+ }
511
+ // `args.filePath` is the DB key (backup/delete/insert/result), stored
512
+ // verbatim so lookups match (realpath stays in validateFilePath; see
513
+ // BaseDirsConfig for the path policy).
514
+ const visualArg = args.visual;
515
+ const visualQuality = args.visualQuality ?? 'fast';
516
+ // No outer error-mapping catch: failures reach the central dispatcher
517
+ // mapper with their original identity. The inner insert/rollback catch is
518
+ // local-effect only.
519
+ const prepared = isRawData
520
+ ? await this.prepareRawDataIngest(args.filePath)
521
+ : await this.prepareSourceFileIngest(args.filePath, {
522
+ images: supportsEmbeddedImages(args.filePath) && (options.images ?? this.storeImages),
523
+ ...(visualArg === true
524
+ ? {
525
+ captioner: { profile: visualQuality, cacheDir: this.cacheDir, device: this.device },
526
+ }
527
+ : {}),
528
+ });
529
+ const title = prepared.title;
530
+ let vectorChunks = prepared.vectorChunks;
531
+ const preparedFile = prepared.preparedFile;
532
+ if (prepared.omittedImageCount > 0) {
533
+ console.warn(`Skipped ${prepared.omittedImageCount} undecodable or oversized image(s) in ${args.filePath}`);
534
+ }
535
+ // Fail-fast: Prevent data loss when chunking produces 0 chunks
536
+ // This check must happen BEFORE delete to preserve existing data on re-ingest
537
+ const chunkCount = vectorChunks?.length ?? preparedFile?.chunks.length ?? 0;
538
+ if (chunkCount === 0) {
539
+ throw new NoChunksError(ErrorCode.InvalidParams, `No chunks generated from file: ${args.filePath}. The file may be empty or all content was filtered (minimum ${this.minChunkLength} characters required). Existing data has been preserved.`);
540
+ }
541
+ // Back up existing chunks BEFORE the destructive delete, with their real
542
+ // stored vectors, so a failed re-ingest rolls back without corrupting them.
543
+ // A failed read propagates from here, leaving the existing data untouched,
544
+ // rather than proceeding into the delete with a partial backup.
545
+ const backup = await this.vectorStore.getChunksByFilePath(args.filePath);
546
+ if (backup.length > 0) {
547
+ console.error(`Backup created: ${backup.length} chunks for ${args.filePath}`);
548
+ }
549
+ // Preserve the original server ordering: row construction follows the
550
+ // backup read but still completes before the destructive delete.
551
+ if (preparedFile !== undefined) {
552
+ vectorChunks = buildPreparedFileVectorChunks(preparedFile);
553
+ }
554
+ if (vectorChunks === undefined) {
555
+ throw new DatabaseError(`No chunks were prepared for ingest: ${args.filePath}`);
556
+ }
557
+ const chunksToInsert = vectorChunks;
558
+ // Delete existing data
559
+ await this.vectorStore.deleteChunks(args.filePath);
560
+ console.error(`Deleted existing chunks for: ${args.filePath}`);
561
+ // Insert vectors (transaction processing)
562
+ try {
563
+ await this.vectorStore.insertChunks(chunksToInsert);
564
+ console.error(`Inserted ${chunksToInsert.length} chunks for: ${args.filePath}`);
565
+ }
566
+ catch (insertError) {
567
+ console.error('Ingestion failed, rolling back...', insertError);
568
+ await this.rollbackIngest(args.filePath, backup, insertError);
569
+ throw insertError;
570
+ }
571
+ return {
572
+ filePath: args.filePath,
573
+ chunkCount,
574
+ timestamp: new Date().toISOString(),
575
+ fileTitle: title || null,
576
+ };
577
+ }
578
+ /**
579
+ * Saves raw content under raw-data and re-enters `handleIngestFile`. HTML is
580
+ * reduced to its main content and converted to Markdown first, so chunking
581
+ * sees prose rather than markup.
582
+ */
583
+ async handleIngestData(args) {
584
+ // ingest_data writes only under `dbPath`/raw-data and never reads a
585
+ // configured `baseDir`, so it stays callable in degraded mode: a user with
586
+ // invalid BASE_DIRS can still capture raw-data while diagnosing. The
587
+ // internal `handleIngestFile` call takes a generated raw-data path, which
588
+ // routes around `parser.validateFilePath`.
589
+ let contentToSave = args.content;
590
+ let title = null;
591
+ // Per-format title extraction and content preparation
592
+ if (args.metadata.format === 'html') {
593
+ console.error(`Parsing HTML from: ${args.metadata.source}`);
594
+ const { content: markdown, title: htmlTitle } = await parseHtml(args.content, args.metadata.source);
595
+ if (!markdown.trim()) {
596
+ throw new Error('Failed to extract content from HTML. The page may have no readable content.');
597
+ }
598
+ title = htmlTitle || null;
599
+ contentToSave = markdown;
600
+ console.error(`Converted HTML to Markdown: ${markdown.length} characters`);
601
+ }
602
+ else if (args.metadata.format === 'markdown') {
603
+ const result = extractMarkdownTitle(args.content, args.metadata.source);
604
+ title = result.source !== 'filename' ? result.title : null;
605
+ }
606
+ else {
607
+ // text format
608
+ const result = extractTxtTitle(args.content, args.metadata.source);
609
+ title = result.source !== 'filename' ? result.title : null;
610
+ }
611
+ const rawDataPath = generateRawDataPath(this.dbPath, args.metadata.source);
612
+ const artifactPaths = [rawDataPath, generateMetaJsonPath(rawDataPath)];
613
+ // Capture both artifacts before either write. Only ENOENT means creation;
614
+ // an unreadable existing source must fail before it can be overwritten.
615
+ const previousArtifacts = await Promise.all(artifactPaths.map(async (path) => {
616
+ try {
617
+ return { path, content: await readFile(path) };
618
+ }
619
+ catch (error) {
620
+ if (!isEnoent(error)) {
621
+ throw error;
622
+ }
623
+ return { path, content: null };
624
+ }
625
+ }));
626
+ let result;
627
+ try {
628
+ await saveRawData(this.dbPath, args.metadata.source, contentToSave);
629
+ await saveMetaJson(rawDataPath, {
630
+ title,
631
+ source: args.metadata.source,
632
+ format: args.metadata.format,
633
+ });
634
+ console.error(`Saved raw data: ${args.metadata.source} -> ${rawDataPath}`);
635
+ result = await this.handleIngestFile({ filePath: rawDataPath }, { skipOptimize: true });
636
+ }
637
+ catch (ingestError) {
638
+ const restored = await Promise.allSettled(previousArtifacts.map(async ({ path, content }) => {
639
+ if (content !== null) {
640
+ await writeFile(path, content);
641
+ }
642
+ else {
643
+ try {
644
+ await unlink(path);
645
+ }
646
+ catch (error) {
647
+ if (!isEnoent(error)) {
648
+ throw error;
649
+ }
650
+ }
651
+ }
652
+ }));
653
+ if (restored.some((outcome) => outcome.status === 'rejected')) {
654
+ console.warn(`Failed to rollback raw-data file: ${rawDataPath}`);
655
+ throw new DatabaseError(`Ingest failed and raw-data rollback failed for ${rawDataPath}; original artifacts may not have been restored.`, { cause: toError(ingestError) });
656
+ }
657
+ console.error(`Rolled back raw-data file: ${rawDataPath}`);
658
+ throw ingestError;
659
+ }
660
+ // Both artifacts and index now describe the replacement. A maintenance
661
+ // failure remains visible to the caller without undoing committed content.
662
+ await this.vectorStore.optimize();
663
+ return result;
664
+ }
665
+ /**
666
+ * Scans the normal-path roots (`this.rawBaseDirs`) so scanned paths match the
667
+ * resolve()-stored DB keys (see {@link BaseDirsConfig}).
668
+ *
669
+ * Multi-root contract: a top-level `baseDir` is still reported as
670
+ * `rawBaseDirs[0]` for clients written against the single-root shape;
671
+ * duplicate paths across roots collapse to the first occurrence in root
672
+ * order; raw-data and orphaned DB entries stay under `sources` with no
673
+ * producing root.
674
+ */
675
+ async handleListFiles(input = {}) {
676
+ // Root-dependent tool: fail fast on configError BEFORE any DB / FS access.
677
+ // `assertConfigOk` throws `BaseDirsConfigError` (mapped to InvalidParams by
678
+ // the central dispatcher); no local error-mapping catch here.
679
+ this.assertConfigOk();
680
+ // `input.scope` is parser-normalized to `string[]`, but the shared input
681
+ // type admits `string | string[]`; array-wrap once (mirrors query_documents)
682
+ // so scope threads uniformly into the walker and the sources classifier.
683
+ // Undefined scope leaves both the scan and the sources split unchanged.
684
+ const scope = input.scope === undefined ? undefined : toArray(input.scope);
685
+ const ingested = await this.vectorStore.listFiles();
686
+ const listed = await listDocuments({
687
+ roots: this.rawBaseDirs,
688
+ dbPath: this.dbPath,
689
+ ingested,
690
+ scope,
691
+ scan: (baseDir, scanScope) => scanBaseDir(baseDir, this.excludePaths, scanScope),
692
+ });
693
+ const files = listed.files;
694
+ const sources = listed.sources;
695
+ const result = {
696
+ baseDir: this.rawBaseDir,
697
+ baseDirs: [...this.rawBaseDirs],
698
+ files,
699
+ sources,
700
+ };
701
+ // Build the response with the primary JSON block first, then any
702
+ // per-root scan warnings as additional text blocks so
703
+ // clients see the warnings alongside the file list without needing
704
+ // to inspect stderr. Config-level warnings (`configWarnings`) are
705
+ // still appended via `withWarnings`.
706
+ const content = [{ type: 'text', text: JSON.stringify(result, null, 2) }];
707
+ for (const warning of listed.warnings) {
708
+ content.push({
709
+ type: 'text',
710
+ text: `Warning: [${warning.baseDir}] ${warning.message}`,
711
+ });
712
+ }
713
+ // A non-absolute scope prefix matches nothing (the scan is absolute-path
714
+ // based) but yields no result-level signal, so surface it as a non-fatal
715
+ // warning block. Result semantics are unchanged — the prefix still matches
716
+ // nothing; this only makes the silent miss visible to the client.
717
+ if (scope !== undefined) {
718
+ for (const prefix of nonAbsolutePrefixes(scope)) {
719
+ content.push({
720
+ type: 'text',
721
+ text: `Warning: scope prefix "${prefix}" is not absolute; it matches nothing.`,
722
+ });
723
+ }
724
+ }
725
+ return { content: this.withWarnings(content) };
726
+ }
727
+ /**
728
+ * status tool handler
729
+ */
730
+ async handleStatus() {
731
+ // `status` remains callable in degraded mode (configError set) so the
732
+ // user can diagnose the root configuration via MCP without inspecting
733
+ // stderr. Do NOT call `assertConfigOk` here — status surfaces the config
734
+ // error as a diagnostic content block instead of throwing. No local
735
+ // error-mapping catch: genuine DB failures propagate (prefix-less) to the
736
+ // central dispatcher mapper.
737
+ const status = await this.vectorStore.getStatus();
738
+ const content = [
739
+ {
740
+ type: 'text',
741
+ text: JSON.stringify(status, null, 2),
742
+ },
743
+ ];
744
+ // Surface the configError as a diagnostic content block when present.
745
+ // Placed BEFORE warning blocks so it appears with the primary status
746
+ // payload at a higher priority annotation.
747
+ if (this.configError !== null) {
748
+ content.push(buildConfigErrorBlock(this.configError.message));
749
+ }
750
+ return { content: this.withWarnings(content) };
751
+ }
752
+ /**
753
+ * delete_file tool handler
754
+ * Deletes chunks from VectorDB and physical raw-data files
755
+ * Supports both filePath (for ingest_file) and source (for ingest_data)
756
+ */
757
+ async handleDeleteFile(raw) {
758
+ const args = parseDeleteFileInput(raw);
759
+ // No outer error-mapping catch: the inline `McpError(InvalidParams)` and
760
+ // `assertConfigOk` throw propagate with original identity to the central
761
+ // dispatcher mapper. The inner unlink try/catch blocks below are
762
+ // local-effect (best-effort file cleanup) and are retained.
763
+ const targetPath = await this.resolveDocumentTarget(args);
764
+ // Delete chunks from vector database
765
+ const removedChunks = await this.vectorStore.deleteChunks(targetPath);
766
+ // Optimize immediately after the DB delete: a later raw-data unlink failure
767
+ // must not skip compaction once the rows are already gone.
768
+ await this.vectorStore.optimize();
769
+ let rawDataExisted = false;
770
+ let metaExisted = false;
771
+ // Also delete physical raw-data file if applicable.
772
+ if (isPathInRawDataDirLexical(targetPath, this.dbPath)) {
773
+ // Pre-unlink existence (shared with the CLI delete path).
774
+ const artifacts = await checkRawDataArtifacts(targetPath);
775
+ rawDataExisted = artifacts.rawDataExisted;
776
+ metaExisted = artifacts.metaExisted;
777
+ try {
778
+ await unlink(targetPath);
779
+ console.error(`Deleted raw-data file: ${targetPath}`);
780
+ }
781
+ catch (error) {
782
+ if (!isEnoent(error)) {
783
+ throw error;
784
+ }
785
+ console.warn(`Could not delete raw-data file (may not exist): ${targetPath}`);
786
+ }
787
+ try {
788
+ await unlink(generateMetaJsonPath(targetPath));
789
+ console.error(`Deleted meta.json: ${generateMetaJsonPath(targetPath)}`);
790
+ }
791
+ catch (error) {
792
+ if (!isEnoent(error)) {
793
+ throw error;
794
+ }
795
+ }
796
+ }
797
+ const result = {
798
+ filePath: targetPath,
799
+ deleted: true,
800
+ removedChunks,
801
+ existed: removedChunks > 0 || rawDataExisted || metaExisted,
802
+ timestamp: new Date().toISOString(),
803
+ };
804
+ return {
805
+ content: this.withWarnings([
806
+ {
807
+ type: 'text',
808
+ text: JSON.stringify(result, null, 2),
809
+ },
810
+ ]),
811
+ };
812
+ }
813
+ /**
814
+ * Context expansion around a chunkIndex, not a search tool. Mirrors
815
+ * handleDeleteFile's filePath-XOR-source resolution.
816
+ */
817
+ async handleReadChunkNeighbors(raw) {
818
+ const args = parseReadChunkNeighborsInput(raw);
819
+ // No local error-mapping catch: `assertConfigOk` errors propagate with original identity to the
820
+ // central dispatcher mapper. A `DatabaseError` reaches the mapper as a
821
+ // recognized `AppError` and so stays prefix-less (no "Failed to read chunk
822
+ // neighbors" prefix); only a native error picks up that prefix.
823
+ const before = args.before ?? 2;
824
+ const after = args.after ?? 2;
825
+ const targetPath = await this.resolveDocumentTarget(args);
826
+ // Range composition (handler-side clamp; primitive stays feature-agnostic).
827
+ const minIdx = Math.max(0, args.chunkIndex - before);
828
+ const maxIdx = args.chunkIndex + after;
829
+ // Primitive call.
830
+ const rows = await this.vectorStore.getChunksByRange(targetPath, minIdx, maxIdx);
831
+ // Post-fetch marking: isTarget per item; source attached for raw-data rows.
832
+ const isRaw = isManagedRawDataPath(targetPath, this.dbPath);
833
+ const sourceForAll = isRaw ? extractSourceFromPath(targetPath) : null;
834
+ const items = rows.map((row) => {
835
+ const item = {
836
+ filePath: row.filePath,
837
+ chunkIndex: row.chunkIndex,
838
+ text: row.text,
839
+ isTarget: row.chunkIndex === args.chunkIndex,
840
+ fileTitle: row.fileTitle ?? null,
841
+ };
842
+ if (sourceForAll) {
843
+ item.source = sourceForAll;
844
+ }
845
+ return item;
846
+ });
847
+ return {
848
+ content: this.withWarnings([
849
+ {
850
+ type: 'text',
851
+ text: JSON.stringify(items, null, 2),
852
+ },
853
+ ]),
854
+ };
855
+ }
856
+ /** Resolve the shared filePath/source reference without changing its DB-key spelling. */
857
+ async resolveDocumentTarget(reference) {
858
+ if ('source' in reference) {
859
+ // Generated raw-data paths do not depend on configured document roots, so
860
+ // source-mode operations remain callable while root configuration is invalid.
861
+ return generateRawDataPath(this.dbPath, reference.source);
862
+ }
863
+ this.assertConfigOk();
864
+ await this.parser.validateFilePath(reference.filePath);
865
+ return reference.filePath;
866
+ }
867
+ /**
868
+ * Registers the one current job and answers with its id without waiting: the
869
+ * caller polls `sync_status`.
870
+ *
871
+ * The scheduled promise is deliberately floating — an unexpected rejection is
872
+ * captured into the job record rather than escaping, and the run holds the
873
+ * external-mutation guard until it is terminal.
874
+ */
875
+ async handleSyncStart(input) {
876
+ // Root-dependent tool: fail fast on configError before registering a job.
877
+ this.assertConfigOk();
878
+ const jobId = randomUUID();
879
+ this.syncJob = {
880
+ jobId,
881
+ state: 'running',
882
+ total: null,
883
+ completed: 0,
884
+ summary: { upserted: 0, skipped: 0, empty: 0, pruned: 0 },
885
+ warnings: [],
886
+ error: null,
887
+ };
888
+ this.runSyncJob(jobId, input.path)
889
+ .catch((error) => {
890
+ // Only an unexpected orchestration failure lands here: `runSync` already
891
+ // returns its own controlled error. One error, no rollback, no retry.
892
+ this.updateSyncJob(jobId, { state: 'failed', error: formatErrorForClient(error) });
893
+ })
894
+ .finally(() => {
895
+ this.releaseMutation();
896
+ });
897
+ return {
898
+ content: this.withWarnings([{ type: 'text', text: JSON.stringify({ jobId }, null, 2) }]),
899
+ };
900
+ }
901
+ /**
902
+ * Read-only, so it stays callable while a sync holds the mutation guard. Any
903
+ * id but the current one is unknown — replaced by a newer `sync_start`, or
904
+ * lost with a previous server process.
905
+ */
906
+ async handleSyncStatus(input) {
907
+ const job = this.syncJob;
908
+ if (job === null || job.jobId !== input.jobId) {
909
+ throw new McpError(ErrorCode.InvalidParams, `Unknown sync job: ${input.jobId}. Only the current or latest job is kept: it is replaced by a newer sync_start and discarded when the server process exits.`);
910
+ }
911
+ return {
912
+ content: this.withWarnings([{ type: 'text', text: JSON.stringify(job, null, 2) }]),
913
+ };
914
+ }
915
+ /** Patch the current job, ignoring a write aimed at a record already replaced. */
916
+ updateSyncJob(jobId, patch) {
917
+ if (this.syncJob === null || this.syncJob.jobId !== jobId) {
918
+ return;
919
+ }
920
+ this.syncJob = { ...this.syncJob, ...patch };
921
+ }
922
+ /**
923
+ * One sync job's body: supply the real collaborators to the shared core and
924
+ * fold its result into the pollable record. Planning and the
925
+ * stop-on-first-error policy stay in the core, so path classification and
926
+ * depth match the CLI exactly.
927
+ */
928
+ async runSyncJob(jobId, requestedPath) {
929
+ let hashedFiles = 0;
930
+ let ingestedFiles = 0;
931
+ const collaborators = {
932
+ // The containment boundary for a client-supplied path: the core compares
933
+ // this canonical form against the realpath'd roots, which is the only way
934
+ // to see that an intermediate component is a symbolic link out of the root.
935
+ // `ingest_file` validates the same way, so both tools refuse the same paths.
936
+ canonicalizeRequestedPath,
937
+ // The walker's own predicates, so an explicitly requested path is subject
938
+ // to the same rules as a discovered one and is refused before it is read.
939
+ // A path whose read would never return (a FIFO) is refused here too, which
940
+ // matters more on this surface than on the CLI: the mutation guard is
941
+ // released by this job's promise settling, and nothing else would.
942
+ classifyPath: async (path) => await classifyRequestedPath(path, this.excludePaths),
943
+ // No `scope` argument, on purpose: a scope-pruned directory appears in
944
+ // none of the coverage arrays, which would hide an unobserved region and
945
+ // make prune unsafe.
946
+ scanDir: async (rootPath) => await bfsCollectSupportedFiles(rootPath, this.excludePaths, { maxDepth: MAX_SCAN_DEPTH }),
947
+ // Size first, bytes second: `maxFileSize` is otherwise enforced inside the
948
+ // parser, which runs long after the whole file would already be in memory
949
+ // here. Declining (`null`) keeps the rest of the run usable instead of
950
+ // failing every future sync of the whole root on one oversized file.
951
+ //
952
+ // The bound holds only against a non-racing filesystem: a writer that grows
953
+ // the file, or replaces it with a FIFO, between the `stat` and the
954
+ // `readFile` restores the unbounded read or an indefinite block. That actor
955
+ // needs local write access as this same user and can already reach the
956
+ // database directly, so this is a recorded limitation rather than a defended
957
+ // boundary — as with the watchdog limitation noted on the mutation guard.
958
+ hashFile: async (filePath) => {
959
+ if ((await stat(filePath)).size > this.maxFileSize) {
960
+ return null;
961
+ }
962
+ const contentHash = computeContentHash(await readFile(filePath));
963
+ hashedFiles += 1;
964
+ return contentHash;
965
+ },
966
+ loadDbManifest: async () => {
967
+ // The core hashes every scanned file before it loads the manifest, so
968
+ // this is the first moment the supported-file count is final.
969
+ this.updateSyncJob(jobId, { total: hashedFiles });
970
+ return await this.vectorStore.listSyncManifest();
971
+ },
972
+ ingestFile: async (filePath, options) => {
973
+ const chunkCount = await this.ingestFileForSync(filePath, options);
974
+ ingestedFiles += 1;
975
+ this.updateSyncJob(jobId, { completed: ingestedFiles });
976
+ return chunkCount;
977
+ },
978
+ deleteExactPath: async (filePath) => await this.vectorStore.deleteChunks(filePath),
979
+ optimize: async () => {
980
+ await this.vectorStore.optimize();
981
+ },
982
+ };
983
+ const result = await runSync({
984
+ roots: this.rawBaseDirs,
985
+ // The realpath'd counterpart of the same roots, which is what the core
986
+ // decides requested-path containment in (the parser's boundary domain).
987
+ canonicalRoots: this.baseDirs,
988
+ dbPath: this.dbPath,
989
+ excludePaths: this.excludePaths,
990
+ platform: process.platform,
991
+ // resolve() (never realpath) so the requested path is spelled like the
992
+ // stored DB keys; the core validates it against the configured roots.
993
+ ...(requestedPath === undefined ? {} : { requestedPath: resolve(requestedPath) }),
994
+ ...(this.storeImages ? { images: true } : {}),
995
+ collaborators,
996
+ });
997
+ this.updateSyncJob(jobId, {
998
+ state: result.error === null ? 'succeeded' : 'failed',
999
+ // Skips are only known once the plan has run, so the final value can only
1000
+ // grow: a poll never sees `completed` go backwards.
1001
+ completed: result.upserted + result.skipped + result.empty,
1002
+ summary: {
1003
+ upserted: result.upserted,
1004
+ skipped: result.skipped,
1005
+ empty: result.empty,
1006
+ pruned: result.pruned,
1007
+ },
1008
+ warnings: coverageWarnings(result.coverage, this.maxFileSize),
1009
+ error: result.error === null ? null : formatSyncError(result.error),
1010
+ });
1011
+ }
1012
+ /**
1013
+ * Sync's `ingestFile` uses the same typed operation as `ingest_file`, keeping
1014
+ * its backup and rollback semantics. A zero-chunk file is rejected before the
1015
+ * delete, so prior rows survive, and is reported as `empty`.
1016
+ *
1017
+ * The planner's resolved profile is expressed through the same `visual` /
1018
+ * `visualQuality` arguments a direct `ingest_file` call uses, so inheritance
1019
+ * reuses the configured cache and device rather than a second VLM route.
1020
+ *
1021
+ * Per-file compaction is skipped because the sync core runs one `optimize()`
1022
+ * for the whole run. A rollback still compacts — that path restores rows and
1023
+ * then aborts, so no later `optimize()` follows.
1024
+ */
1025
+ async ingestFileForSync(filePath, { images, visualProfile }) {
1026
+ try {
1027
+ const result = await this.ingestFile(visualProfile === null
1028
+ ? { filePath }
1029
+ : { filePath, visual: true, visualQuality: visualProfile }, { images });
1030
+ return result.chunkCount;
1031
+ }
1032
+ catch (error) {
1033
+ if (error instanceof NoChunksError) {
1034
+ return 0;
1035
+ }
1036
+ throw error;
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Serve this instance's tool registration over `transport` — the registration
1041
+ * itself, not a copy, since `this.server` is private. `run()` passes stdio; a
1042
+ * test passes an in-memory pair.
1043
+ *
1044
+ * One instance serves at most one client: the job record and the mutation slot
1045
+ * are per-process, so a multiplexing transport would share one caller's write
1046
+ * lock with every other caller.
1047
+ */
1048
+ async connect(transport) {
1049
+ await this.server.connect(transport);
1050
+ }
1051
+ /**
1052
+ * Start the server
1053
+ */
1054
+ async run() {
1055
+ await this.connect(new StdioServerTransport());
1056
+ console.error('RAGServer running on stdio transport');
1057
+ }
1058
+ /**
1059
+ * Stop the server and release resources
1060
+ */
1061
+ async close() {
1062
+ await this.server.close();
1063
+ await this.vectorStore.close();
1064
+ await this.embedder.dispose();
1065
+ console.error('RAGServer stopped');
1066
+ }
1067
+ }
1068
+ //# sourceMappingURL=index.js.map