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.
- package/LICENSE +21 -0
- package/README.de.md +416 -0
- package/README.es.md +416 -0
- package/README.fr.md +416 -0
- package/README.md +491 -0
- package/README.pt-BR.md +416 -0
- package/README.zh-CN.md +416 -0
- package/dist/bin/install-skills.d.ts +20 -0
- package/dist/bin/install-skills.d.ts.map +1 -0
- package/dist/bin/install-skills.js +195 -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 +97 -0
- package/dist/chunker/semantic-chunker.d.ts.map +1 -0
- package/dist/chunker/semantic-chunker.js +294 -0
- package/dist/chunker/semantic-chunker.js.map +1 -0
- package/dist/chunker/sentence-splitter.d.ts +28 -0
- package/dist/chunker/sentence-splitter.d.ts.map +1 -0
- package/dist/chunker/sentence-splitter.js +219 -0
- package/dist/chunker/sentence-splitter.js.map +1 -0
- package/dist/cli/common.d.ts +65 -0
- package/dist/cli/common.d.ts.map +1 -0
- package/dist/cli/common.js +138 -0
- package/dist/cli/common.js.map +1 -0
- package/dist/cli/delete.d.ts +8 -0
- package/dist/cli/delete.d.ts.map +1 -0
- package/dist/cli/delete.js +173 -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 +100 -0
- package/dist/cli/ingest.d.ts.map +1 -0
- package/dist/cli/ingest.js +363 -0
- package/dist/cli/ingest.js.map +1 -0
- package/dist/cli/list.d.ts +35 -0
- package/dist/cli/list.d.ts.map +1 -0
- package/dist/cli/list.js +210 -0
- package/dist/cli/list.js.map +1 -0
- package/dist/cli/options.d.ts +100 -0
- package/dist/cli/options.d.ts.map +1 -0
- package/dist/cli/options.js +241 -0
- package/dist/cli/options.js.map +1 -0
- package/dist/cli/query.d.ts +24 -0
- package/dist/cli/query.d.ts.map +1 -0
- package/dist/cli/query.js +191 -0
- package/dist/cli/query.js.map +1 -0
- package/dist/cli/read-neighbors.d.ts +11 -0
- package/dist/cli/read-neighbors.d.ts.map +1 -0
- package/dist/cli/read-neighbors.js +224 -0
- package/dist/cli/read-neighbors.js.map +1 -0
- package/dist/cli/status.d.ts +8 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +80 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/sync.d.ts +8 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +244 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/cli-main.d.ts +12 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +63 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/embedder/index.d.ts +85 -0
- package/dist/embedder/index.d.ts.map +1 -0
- package/dist/embedder/index.js +284 -0
- package/dist/embedder/index.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 +40 -0
- package/dist/features/list.js.map +1 -0
- package/dist/features/sync.d.ts +207 -0
- package/dist/features/sync.d.ts.map +1 -0
- package/dist/features/sync.js +380 -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 +53 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/compute.d.ts +86 -0
- package/dist/ingest/compute.d.ts.map +1 -0
- package/dist/ingest/compute.js +177 -0
- package/dist/ingest/compute.js.map +1 -0
- package/dist/ingest/file.d.ts +27 -0
- package/dist/ingest/file.d.ts.map +1 -0
- package/dist/ingest/file.js +67 -0
- package/dist/ingest/file.js.map +1 -0
- package/dist/ingest/visual.d.ts +45 -0
- package/dist/ingest/visual.d.ts.map +1 -0
- package/dist/ingest/visual.js +234 -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 +328 -0
- package/dist/parser/docx-parser.js.map +1 -0
- package/dist/parser/html-parser.d.ts +18 -0
- package/dist/parser/html-parser.d.ts.map +1 -0
- package/dist/parser/html-parser.js +102 -0
- package/dist/parser/html-parser.js.map +1 -0
- package/dist/parser/index.d.ts +214 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +454 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/pdf-extract.d.ts +81 -0
- package/dist/parser/pdf-extract.d.ts.map +1 -0
- package/dist/parser/pdf-extract.js +112 -0
- package/dist/parser/pdf-extract.js.map +1 -0
- package/dist/parser/pdf-filter.d.ts +117 -0
- package/dist/parser/pdf-filter.d.ts.map +1 -0
- package/dist/parser/pdf-filter.js +528 -0
- package/dist/parser/pdf-filter.js.map +1 -0
- package/dist/parser/title-extractor.d.ts +69 -0
- package/dist/parser/title-extractor.d.ts.map +1 -0
- package/dist/parser/title-extractor.js +145 -0
- package/dist/parser/title-extractor.js.map +1 -0
- package/dist/pdf-visual/captioner.d.ts +16 -0
- package/dist/pdf-visual/captioner.d.ts.map +1 -0
- package/dist/pdf-visual/captioner.js +63 -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 +103 -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 +127 -0
- package/dist/pdf-visual/captioners/quality.js.map +1 -0
- package/dist/pdf-visual/captioners/shared.d.ts +44 -0
- package/dist/pdf-visual/captioners/shared.d.ts.map +1 -0
- package/dist/pdf-visual/captioners/shared.js +104 -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 +234 -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 +45 -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 +177 -0
- package/dist/pdf-visual/renderer.js.map +1 -0
- package/dist/pdf-visual/types.d.ts +62 -0
- package/dist/pdf-visual/types.d.ts.map +1 -0
- package/dist/pdf-visual/types.js +32 -0
- package/dist/pdf-visual/types.js.map +1 -0
- package/dist/server/error-utils.d.ts +79 -0
- package/dist/server/error-utils.d.ts.map +1 -0
- package/dist/server/error-utils.js +148 -0
- package/dist/server/error-utils.js.map +1 -0
- package/dist/server/index.d.ts +258 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1104 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/list-scanner.d.ts +52 -0
- package/dist/server/list-scanner.d.ts.map +1 -0
- package/dist/server/list-scanner.js +72 -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 +216 -0
- package/dist/server/tool-input.js.map +1 -0
- package/dist/server/types.d.ts +331 -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 +46 -0
- package/dist/server-main.d.ts.map +1 -0
- package/dist/server-main.js +242 -0
- package/dist/server-main.js.map +1 -0
- package/dist/utils/base-dirs.d.ts +212 -0
- package/dist/utils/base-dirs.d.ts.map +1 -0
- package/dist/utils/base-dirs.js +422 -0
- package/dist/utils/base-dirs.js.map +1 -0
- package/dist/utils/errors.d.ts +24 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +53 -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 +47 -0
- package/dist/utils/list-sources.d.ts.map +1 -0
- package/dist/utils/list-sources.js +50 -0
- package/dist/utils/list-sources.js.map +1 -0
- package/dist/utils/raw-data-utils.d.ts +131 -0
- package/dist/utils/raw-data-utils.d.ts.map +1 -0
- package/dist/utils/raw-data-utils.js +255 -0
- package/dist/utils/raw-data-utils.js.map +1 -0
- package/dist/utils/scan.d.ts +126 -0
- package/dist/utils/scan.d.ts.map +1 -0
- package/dist/utils/scan.js +221 -0
- package/dist/utils/scan.js.map +1 -0
- package/dist/utils/scope-match.d.ts +43 -0
- package/dist/utils/scope-match.d.ts.map +1 -0
- package/dist/utils/scope-match.js +87 -0
- package/dist/utils/scope-match.js.map +1 -0
- package/dist/utils/sensitive-path.d.ts +23 -0
- package/dist/utils/sensitive-path.d.ts.map +1 -0
- package/dist/utils/sensitive-path.js +91 -0
- package/dist/utils/sensitive-path.js.map +1 -0
- package/dist/utils/sync-path-key.d.ts +20 -0
- package/dist/utils/sync-path-key.d.ts.map +1 -0
- package/dist/utils/sync-path-key.js +33 -0
- package/dist/utils/sync-path-key.js.map +1 -0
- package/dist/vectordb/index.d.ts +168 -0
- package/dist/vectordb/index.d.ts.map +1 -0
- package/dist/vectordb/index.js +619 -0
- package/dist/vectordb/index.js.map +1 -0
- package/dist/vectordb/search-filters.d.ts +39 -0
- package/dist/vectordb/search-filters.d.ts.map +1 -0
- package/dist/vectordb/search-filters.js +136 -0
- package/dist/vectordb/search-filters.js.map +1 -0
- package/dist/vectordb/types.d.ts +196 -0
- package/dist/vectordb/types.d.ts.map +1 -0
- package/dist/vectordb/types.js +224 -0
- package/dist/vectordb/types.js.map +1 -0
- package/package.json +105 -0
- package/skills/mcp-local-rag/SKILL.md +308 -0
- package/skills/mcp-local-rag/references/cli-reference.md +175 -0
- package/skills/mcp-local-rag/references/html-ingestion.md +78 -0
- package/skills/mcp-local-rag/references/query-optimization.md +57 -0
- 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
|