@ruvector/rvf-mcp-server 0.1.6 → 0.2.1

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/src/stores.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * On-disk store plumbing for the RVF MCP server: data-dir path confinement
3
+ * and the per-store metadata log.
4
+ *
5
+ * Vectors live in a real `.rvf` file (via @ruvector/rvf). The native backend
6
+ * does not store per-vector metadata, so the server keeps it beside the store
7
+ * in `<store>.mcp-meta.jsonl`: a header line, then `["s",id,metadata]` /
8
+ * `["d",id]` ops. It is rewritten (temp file + rename) on close/compact and
9
+ * whenever the appended tail outgrows the last rewrite.
10
+ */
11
+
12
+ import * as fs from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+
16
+ export type Metadata = Record<string, string | number | boolean>;
17
+
18
+ export const META_SUFFIX = '.mcp-meta.jsonl';
19
+ const META_VERSION = 1;
20
+ const META_COMPACT_MIN_BYTES = 1 << 20;
21
+
22
+ /** Default data directory: $RVF_MCP_DATA_DIR, else ~/.rvf-mcp-server. */
23
+ export function defaultDataDir(): string {
24
+ return process.env.RVF_MCP_DATA_DIR || path.join(os.homedir(), '.rvf-mcp-server');
25
+ }
26
+
27
+ /**
28
+ * Resolve a model-supplied store path inside `dataDir`.
29
+ *
30
+ * Tool arguments are untrusted: absolute paths, `..` segments, NUL bytes and
31
+ * symlinks that lead outside the data dir are all rejected, and containment
32
+ * is checked before anything is created. With `create`, missing
33
+ * intermediate directories are then made inside the data dir.
34
+ */
35
+ export function resolveStorePath(dataDir: string, storePath: string, create: boolean): string {
36
+ if (typeof storePath !== 'string' || storePath.length === 0 || storePath.length > 1024) {
37
+ throw new Error('store path must be a non-empty relative path');
38
+ }
39
+ if (storePath.includes('\0')) throw new Error('store path contains a NUL byte');
40
+ if (path.isAbsolute(storePath) || path.win32.isAbsolute(storePath)) {
41
+ throw new Error(`store path must be relative to the data dir: ${storePath}`);
42
+ }
43
+ if (storePath.split(/[\\/]+/).includes('..')) {
44
+ throw new Error(`store path must not contain "..": ${storePath}`);
45
+ }
46
+ const root = path.resolve(dataDir);
47
+ const target = path.resolve(root, storePath);
48
+ if (!target.startsWith(root + path.sep)) {
49
+ throw new Error(`store path escapes the data dir: ${storePath}`);
50
+ }
51
+ fs.mkdirSync(root, { recursive: true });
52
+ const realRoot = fs.realpathSync(root);
53
+ // Deepest ancestor that exists (lstat: a dangling symlink counts as existing).
54
+ let probe = path.dirname(target);
55
+ while (probe !== root && lstatOrNull(probe) === null) probe = path.dirname(probe);
56
+ let realProbe: string;
57
+ try {
58
+ realProbe = fs.realpathSync(probe);
59
+ } catch {
60
+ throw new Error(`store path goes through a broken symlink: ${storePath}`);
61
+ }
62
+ if (realProbe !== realRoot && !realProbe.startsWith(realRoot + path.sep)) {
63
+ throw new Error(`store path escapes the data dir via a symlink: ${storePath}`);
64
+ }
65
+ if (lstatOrNull(target)?.isSymbolicLink()) {
66
+ throw new Error(`store path is a symlink: ${storePath}`);
67
+ }
68
+ if (create) fs.mkdirSync(path.dirname(target), { recursive: true });
69
+ return target;
70
+ }
71
+
72
+ function lstatOrNull(p: string): fs.Stats | null {
73
+ try {
74
+ return fs.lstatSync(p);
75
+ } catch (err) {
76
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ export interface MetaHeader {
82
+ dimensions: number;
83
+ metric: string;
84
+ }
85
+
86
+ /** Metadata for one store, persisted as an append-only log. */
87
+ export class MetadataLog {
88
+ readonly entries = new Map<string, Metadata>();
89
+ header: MetaHeader | null = null;
90
+ private bytes = 0;
91
+ private compactedBytes = 0;
92
+
93
+ constructor(readonly file: string, private readonly readOnly: boolean) {}
94
+
95
+ /** Load the log if present. A torn final line (crash mid-append) is ignored. */
96
+ load(): void {
97
+ let text: string;
98
+ try {
99
+ text = fs.readFileSync(this.file, 'utf-8');
100
+ } catch (err) {
101
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
102
+ throw err;
103
+ }
104
+ const lines = text.split('\n');
105
+ // The last element is either '' (clean end) or a torn, unterminated line.
106
+ const complete = lines.length - 1;
107
+ let valid = 0;
108
+ for (let i = 0; i < complete; i++) {
109
+ const parsed = this.parse(lines[i], i === 0);
110
+ if (!parsed) {
111
+ if (lines.slice(i + 1, complete).some((l) => l.length > 0)) {
112
+ throw new Error(`corrupt metadata log ${this.file} at line ${i + 1}`);
113
+ }
114
+ break;
115
+ }
116
+ valid += Buffer.byteLength(lines[i], 'utf-8') + 1;
117
+ }
118
+ if (!this.readOnly && valid < Buffer.byteLength(text, 'utf-8')) {
119
+ fs.truncateSync(this.file, valid);
120
+ }
121
+ this.bytes = valid;
122
+ this.compactedBytes = valid;
123
+ }
124
+
125
+ private parse(line: string, first: boolean): boolean {
126
+ let v: unknown;
127
+ try {
128
+ v = JSON.parse(line);
129
+ } catch {
130
+ return false;
131
+ }
132
+ if (first) {
133
+ const h = v as { rvfMcpMeta?: number; dimensions?: number; metric?: string };
134
+ if (!h || h.rvfMcpMeta !== META_VERSION) return false;
135
+ this.header = { dimensions: Number(h.dimensions), metric: String(h.metric) };
136
+ return true;
137
+ }
138
+ if (!Array.isArray(v) || typeof v[1] !== 'string') return false;
139
+ if (v[0] === 's' && v[2] && typeof v[2] === 'object') {
140
+ this.entries.set(v[1], v[2] as Metadata);
141
+ return true;
142
+ }
143
+ if (v[0] === 'd') {
144
+ this.entries.delete(v[1]);
145
+ return true;
146
+ }
147
+ return false;
148
+ }
149
+
150
+ /** Record new metadata (or its removal) for ids, then persist. */
151
+ apply(sets: Array<[string, Metadata]>, deletes: string[]): void {
152
+ let out = '';
153
+ for (const id of deletes) {
154
+ if (this.entries.delete(id)) out += `${JSON.stringify(['d', id])}\n`;
155
+ }
156
+ for (const [id, meta] of sets) {
157
+ this.entries.set(id, meta);
158
+ out += `${JSON.stringify(['s', id, meta])}\n`;
159
+ }
160
+ if (!out || this.readOnly) return;
161
+ if (this.bytes === 0) {
162
+ this.rewrite();
163
+ return;
164
+ }
165
+ fs.appendFileSync(this.file, out, 'utf-8');
166
+ this.bytes += Buffer.byteLength(out, 'utf-8');
167
+ if (this.bytes - this.compactedBytes > Math.max(META_COMPACT_MIN_BYTES, this.compactedBytes)) {
168
+ this.rewrite();
169
+ }
170
+ }
171
+
172
+ /** Rewrite the log as header + current entries (atomic). */
173
+ rewrite(): void {
174
+ if (this.readOnly || !this.header) return;
175
+ let out = `${JSON.stringify({ rvfMcpMeta: META_VERSION, ...this.header })}\n`;
176
+ for (const [id, meta] of this.entries) out += `${JSON.stringify(['s', id, meta])}\n`;
177
+ const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
178
+ fs.writeFileSync(tmp, out, 'utf-8');
179
+ fs.renameSync(tmp, this.file);
180
+ this.bytes = Buffer.byteLength(out, 'utf-8');
181
+ this.compactedBytes = this.bytes;
182
+ }
183
+ }
184
+
185
+ /** True when `meta` matches every field of `filter` exactly. */
186
+ export function matchesFilter(meta: Metadata | undefined, filter: Metadata): boolean {
187
+ if (!meta) return false;
188
+ for (const [key, val] of Object.entries(filter)) {
189
+ if (meta[key] !== val) return false;
190
+ }
191
+ return true;
192
+ }