abb-opencode-local-rag 0.1.0

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