@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.
- package/LICENSE +21 -0
- package/README.de.md +441 -0
- package/README.es.md +441 -0
- package/README.fr.md +441 -0
- package/README.md +530 -0
- package/README.pt-BR.md +441 -0
- package/README.zh-CN.md +441 -0
- package/dist/bin/install-skills.d.ts +19 -0
- package/dist/bin/install-skills.d.ts.map +1 -0
- package/dist/bin/install-skills.js +218 -0
- package/dist/bin/install-skills.js.map +1 -0
- package/dist/chunker/index.d.ts +24 -0
- package/dist/chunker/index.d.ts.map +1 -0
- package/dist/chunker/index.js +2 -0
- package/dist/chunker/index.js.map +1 -0
- package/dist/chunker/semantic-chunker.d.ts +96 -0
- package/dist/chunker/semantic-chunker.d.ts.map +1 -0
- package/dist/chunker/semantic-chunker.js +362 -0
- package/dist/chunker/semantic-chunker.js.map +1 -0
- package/dist/chunker/sentence-splitter.d.ts +32 -0
- package/dist/chunker/sentence-splitter.d.ts.map +1 -0
- package/dist/chunker/sentence-splitter.js +231 -0
- package/dist/chunker/sentence-splitter.js.map +1 -0
- package/dist/chunker/token-containment.d.ts +39 -0
- package/dist/chunker/token-containment.d.ts.map +1 -0
- package/dist/chunker/token-containment.js +83 -0
- package/dist/chunker/token-containment.js.map +1 -0
- package/dist/cli/common.d.ts +34 -0
- package/dist/cli/common.d.ts.map +1 -0
- package/dist/cli/common.js +121 -0
- package/dist/cli/common.js.map +1 -0
- package/dist/cli/delete.d.ts +3 -0
- package/dist/cli/delete.d.ts.map +1 -0
- package/dist/cli/delete.js +169 -0
- package/dist/cli/delete.js.map +1 -0
- package/dist/cli/file-collection.d.ts +2 -0
- package/dist/cli/file-collection.d.ts.map +1 -0
- package/dist/cli/file-collection.js +53 -0
- package/dist/cli/file-collection.js.map +1 -0
- package/dist/cli/ingest.d.ts +69 -0
- package/dist/cli/ingest.d.ts.map +1 -0
- package/dist/cli/ingest.js +323 -0
- package/dist/cli/ingest.js.map +1 -0
- package/dist/cli/list.d.ts +27 -0
- package/dist/cli/list.d.ts.map +1 -0
- package/dist/cli/list.js +207 -0
- package/dist/cli/list.js.map +1 -0
- package/dist/cli/options.d.ts +89 -0
- package/dist/cli/options.d.ts.map +1 -0
- package/dist/cli/options.js +244 -0
- package/dist/cli/options.js.map +1 -0
- package/dist/cli/query.d.ts +17 -0
- package/dist/cli/query.d.ts.map +1 -0
- package/dist/cli/query.js +194 -0
- package/dist/cli/query.js.map +1 -0
- package/dist/cli/read-neighbors.d.ts +3 -0
- package/dist/cli/read-neighbors.d.ts.map +1 -0
- package/dist/cli/read-neighbors.js +192 -0
- package/dist/cli/read-neighbors.js.map +1 -0
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +78 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/sync.d.ts +6 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +271 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/cli-main.d.ts +9 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +60 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/embedder/index.d.ts +143 -0
- package/dist/embedder/index.d.ts.map +1 -0
- package/dist/embedder/index.js +429 -0
- package/dist/embedder/index.js.map +1 -0
- package/dist/embedder/remote.d.ts +51 -0
- package/dist/embedder/remote.d.ts.map +1 -0
- package/dist/embedder/remote.js +182 -0
- package/dist/embedder/remote.js.map +1 -0
- package/dist/features/list.d.ts +37 -0
- package/dist/features/list.d.ts.map +1 -0
- package/dist/features/list.js +41 -0
- package/dist/features/list.js.map +1 -0
- package/dist/features/sync.d.ts +219 -0
- package/dist/features/sync.d.ts.map +1 -0
- package/dist/features/sync.js +461 -0
- package/dist/features/sync.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/compute.d.ts +63 -0
- package/dist/ingest/compute.d.ts.map +1 -0
- package/dist/ingest/compute.js +161 -0
- package/dist/ingest/compute.js.map +1 -0
- package/dist/ingest/file.d.ts +40 -0
- package/dist/ingest/file.d.ts.map +1 -0
- package/dist/ingest/file.js +74 -0
- package/dist/ingest/file.js.map +1 -0
- package/dist/ingest/visual.d.ts +52 -0
- package/dist/ingest/visual.d.ts.map +1 -0
- package/dist/ingest/visual.js +294 -0
- package/dist/ingest/visual.js.map +1 -0
- package/dist/parser/docx-parser.d.ts +12 -0
- package/dist/parser/docx-parser.d.ts.map +1 -0
- package/dist/parser/docx-parser.js +375 -0
- package/dist/parser/docx-parser.js.map +1 -0
- package/dist/parser/html-parser.d.ts +16 -0
- package/dist/parser/html-parser.d.ts.map +1 -0
- package/dist/parser/html-parser.js +119 -0
- package/dist/parser/html-parser.js.map +1 -0
- package/dist/parser/index.d.ts +125 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +364 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/pdf-extract.d.ts +49 -0
- package/dist/parser/pdf-extract.d.ts.map +1 -0
- package/dist/parser/pdf-extract.js +114 -0
- package/dist/parser/pdf-extract.js.map +1 -0
- package/dist/parser/pdf-filter.d.ts +91 -0
- package/dist/parser/pdf-filter.d.ts.map +1 -0
- package/dist/parser/pdf-filter.js +522 -0
- package/dist/parser/pdf-filter.js.map +1 -0
- package/dist/parser/title-extractor.d.ts +28 -0
- package/dist/parser/title-extractor.d.ts.map +1 -0
- package/dist/parser/title-extractor.js +105 -0
- package/dist/parser/title-extractor.js.map +1 -0
- package/dist/pdf-visual/captioner.d.ts +11 -0
- package/dist/pdf-visual/captioner.d.ts.map +1 -0
- package/dist/pdf-visual/captioner.js +48 -0
- package/dist/pdf-visual/captioner.js.map +1 -0
- package/dist/pdf-visual/captioners/fast.d.ts +7 -0
- package/dist/pdf-visual/captioners/fast.d.ts.map +1 -0
- package/dist/pdf-visual/captioners/fast.js +108 -0
- package/dist/pdf-visual/captioners/fast.js.map +1 -0
- package/dist/pdf-visual/captioners/quality.d.ts +7 -0
- package/dist/pdf-visual/captioners/quality.d.ts.map +1 -0
- package/dist/pdf-visual/captioners/quality.js +101 -0
- package/dist/pdf-visual/captioners/quality.js.map +1 -0
- package/dist/pdf-visual/captioners/shared.d.ts +81 -0
- package/dist/pdf-visual/captioners/shared.d.ts.map +1 -0
- package/dist/pdf-visual/captioners/shared.js +156 -0
- package/dist/pdf-visual/captioners/shared.js.map +1 -0
- package/dist/pdf-visual/detector.d.ts +9 -0
- package/dist/pdf-visual/detector.d.ts.map +1 -0
- package/dist/pdf-visual/detector.js +269 -0
- package/dist/pdf-visual/detector.js.map +1 -0
- package/dist/pdf-visual/index.d.ts +13 -0
- package/dist/pdf-visual/index.d.ts.map +1 -0
- package/dist/pdf-visual/index.js +53 -0
- package/dist/pdf-visual/index.js.map +1 -0
- package/dist/pdf-visual/renderer.d.ts +9 -0
- package/dist/pdf-visual/renderer.d.ts.map +1 -0
- package/dist/pdf-visual/renderer.js +186 -0
- package/dist/pdf-visual/renderer.js.map +1 -0
- package/dist/pdf-visual/types.d.ts +53 -0
- package/dist/pdf-visual/types.d.ts.map +1 -0
- package/dist/pdf-visual/types.js +17 -0
- package/dist/pdf-visual/types.js.map +1 -0
- package/dist/server/error-utils.d.ts +63 -0
- package/dist/server/error-utils.d.ts.map +1 -0
- package/dist/server/error-utils.js +124 -0
- package/dist/server/error-utils.js.map +1 -0
- package/dist/server/index.d.ts +216 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1068 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/list-scanner.d.ts +30 -0
- package/dist/server/list-scanner.d.ts.map +1 -0
- package/dist/server/list-scanner.js +50 -0
- package/dist/server/list-scanner.js.map +1 -0
- package/dist/server/tool-definitions.d.ts +8 -0
- package/dist/server/tool-definitions.d.ts.map +1 -0
- package/dist/server/tool-definitions.js +181 -0
- package/dist/server/tool-definitions.js.map +1 -0
- package/dist/server/tool-input.d.ts +37 -0
- package/dist/server/tool-input.d.ts.map +1 -0
- package/dist/server/tool-input.js +211 -0
- package/dist/server/tool-input.js.map +1 -0
- package/dist/server/types.d.ts +307 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +3 -0
- package/dist/server/types.js.map +1 -0
- package/dist/server-main.d.ts +40 -0
- package/dist/server-main.d.ts.map +1 -0
- package/dist/server-main.js +254 -0
- package/dist/server-main.js.map +1 -0
- package/dist/utils/base-dirs.d.ts +145 -0
- package/dist/utils/base-dirs.d.ts.map +1 -0
- package/dist/utils/base-dirs.js +344 -0
- package/dist/utils/base-dirs.js.map +1 -0
- package/dist/utils/errors.d.ts +32 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +61 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/limits.d.ts +26 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/limits.js +28 -0
- package/dist/utils/limits.js.map +1 -0
- package/dist/utils/list-sources.d.ts +38 -0
- package/dist/utils/list-sources.d.ts.map +1 -0
- package/dist/utils/list-sources.js +40 -0
- package/dist/utils/list-sources.js.map +1 -0
- package/dist/utils/raw-data-utils.d.ts +76 -0
- package/dist/utils/raw-data-utils.d.ts.map +1 -0
- package/dist/utils/raw-data-utils.js +202 -0
- package/dist/utils/raw-data-utils.js.map +1 -0
- package/dist/utils/scan.d.ts +114 -0
- package/dist/utils/scan.d.ts.map +1 -0
- package/dist/utils/scan.js +209 -0
- package/dist/utils/scan.js.map +1 -0
- package/dist/utils/scope-match.d.ts +38 -0
- package/dist/utils/scope-match.d.ts.map +1 -0
- package/dist/utils/scope-match.js +86 -0
- package/dist/utils/scope-match.js.map +1 -0
- package/dist/utils/sensitive-path.d.ts +9 -0
- package/dist/utils/sensitive-path.d.ts.map +1 -0
- package/dist/utils/sensitive-path.js +78 -0
- package/dist/utils/sensitive-path.js.map +1 -0
- package/dist/utils/sync-path-key.d.ts +14 -0
- package/dist/utils/sync-path-key.d.ts.map +1 -0
- package/dist/utils/sync-path-key.js +27 -0
- package/dist/utils/sync-path-key.js.map +1 -0
- package/dist/utils/type-guards.d.ts +18 -0
- package/dist/utils/type-guards.d.ts.map +1 -0
- package/dist/utils/type-guards.js +34 -0
- package/dist/utils/type-guards.js.map +1 -0
- package/dist/utils/visual-profile.d.ts +10 -0
- package/dist/utils/visual-profile.d.ts.map +1 -0
- package/dist/utils/visual-profile.js +15 -0
- package/dist/utils/visual-profile.js.map +1 -0
- package/dist/vectordb/index.d.ts +132 -0
- package/dist/vectordb/index.d.ts.map +1 -0
- package/dist/vectordb/index.js +617 -0
- package/dist/vectordb/index.js.map +1 -0
- package/dist/vectordb/search-filters.d.ts +15 -0
- package/dist/vectordb/search-filters.d.ts.map +1 -0
- package/dist/vectordb/search-filters.js +129 -0
- package/dist/vectordb/search-filters.js.map +1 -0
- package/dist/vectordb/types.d.ts +194 -0
- package/dist/vectordb/types.d.ts.map +1 -0
- package/dist/vectordb/types.js +237 -0
- package/dist/vectordb/types.js.map +1 -0
- package/package.json +108 -0
- package/skills/mcp-local-rag/SKILL.md +234 -0
- package/skills/mcp-local-rag/references/cli-reference.md +190 -0
- 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
|