@ruvector/rvf-mcp-server 0.1.5 → 0.2.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/src/server.ts CHANGED
@@ -1,11 +1,23 @@
1
1
  /**
2
2
  * RVF MCP Server — core server implementation.
3
3
  *
4
- * Registers all RVF tools, resources, and prompts with the MCP SDK.
4
+ * Registers all RVF tools, resources, and prompts with the MCP SDK. Stores
5
+ * are real on-disk `.rvf` files (via @ruvector/rvf) confined to a data dir,
6
+ * so queries are index-backed and data survives server restarts.
5
7
  */
6
8
 
7
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { RvfDatabase } from '@ruvector/rvf';
11
+ import * as fs from 'node:fs';
8
12
  import { z } from 'zod';
13
+ import {
14
+ META_SUFFIX,
15
+ MetadataLog,
16
+ defaultDataDir,
17
+ matchesFilter,
18
+ resolveStorePath,
19
+ type Metadata,
20
+ } from './stores.js';
9
21
 
10
22
  // ─── Types ──────────────────────────────────────────────────────────────────
11
23
 
@@ -18,6 +30,14 @@ export interface RvfMcpServerOptions {
18
30
  defaultDimensions?: number;
19
31
  /** Maximum open stores. Default: 64. */
20
32
  maxStores?: number;
33
+ /**
34
+ * Directory that holds every store. Tool `path` arguments are resolved
35
+ * inside it and may not escape it. Default: $RVF_MCP_DATA_DIR, else
36
+ * ~/.rvf-mcp-server.
37
+ */
38
+ dataDir?: string;
39
+ /** Auto-compaction thresholds after ingest. Default: {@link AUTO_COMPACT}. */
40
+ autoCompact?: { minBytes: number; ratio: number };
21
41
  }
22
42
 
23
43
  /** Upper bounds on model-supplied sizes (tool arguments are untrusted). */
@@ -27,19 +47,39 @@ export const LIMITS = {
27
47
  maxBatch: 10_000,
28
48
  } as const;
29
49
 
50
+ /** Auto-compaction after ingest: file larger than `minBytes` AND `ratio` x live vector bytes. */
51
+ export const AUTO_COMPACT = {
52
+ minBytes: 64 * 1024 * 1024,
53
+ ratio: 8,
54
+ } as const;
55
+
30
56
  const vectorSchema = z.array(z.number()).max(LIMITS.maxDimensions);
31
57
  const metadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
32
58
 
59
+ type Metric = 'l2' | 'cosine' | 'dotproduct';
60
+
33
61
  interface StoreHandle {
34
62
  id: string;
35
63
  path: string;
64
+ file: string;
36
65
  dimensions: number;
37
66
  metric: string;
38
67
  readOnly: boolean;
39
- vectors: Map<string, { vector: number[]; metadata?: Record<string, unknown> }>;
68
+ db: RvfDatabase;
69
+ meta: MetadataLog;
40
70
  createdAt: number;
41
71
  }
42
72
 
73
+ type ToolResult = { content: Array<{ type: 'text'; text: string }> };
74
+
75
+ const reply = (body: unknown): ToolResult => ({
76
+ content: [{ type: 'text' as const, text: JSON.stringify(body, null, 2) }],
77
+ });
78
+ const fail = (message: string): ToolResult => ({
79
+ content: [{ type: 'text' as const, text: `Error: ${message}` }],
80
+ });
81
+ const errMessage = (err: unknown): string => (err instanceof Error ? err.message : String(err));
82
+
43
83
  // ─── Server ─────────────────────────────────────────────────────────────────
44
84
 
45
85
  export class RvfMcpServer {
@@ -54,6 +94,8 @@ export class RvfMcpServer {
54
94
  version: options?.version ?? '0.1.0',
55
95
  defaultDimensions: options?.defaultDimensions ?? 128,
56
96
  maxStores: options?.maxStores ?? 64,
97
+ dataDir: options?.dataDir ?? defaultDataDir(),
98
+ autoCompact: options?.autoCompact ?? AUTO_COMPACT,
57
99
  };
58
100
 
59
101
  this.mcp = new McpServer(
@@ -72,86 +114,113 @@ export class RvfMcpServer {
72
114
  this.registerPrompts();
73
115
  }
74
116
 
117
+ /** Directory that holds this server's stores. */
118
+ get dataDir(): string {
119
+ return this.opts.dataDir;
120
+ }
121
+
122
+ private withStore(
123
+ storeId: string,
124
+ write: boolean,
125
+ fn: (h: StoreHandle) => Promise<ToolResult>,
126
+ ): Promise<ToolResult> {
127
+ const handle = this.stores.get(storeId);
128
+ if (!handle) return Promise.resolve(fail(`store ${storeId} not found`));
129
+ if (write && handle.readOnly) return Promise.resolve(fail('store is read-only'));
130
+ return fn(handle).catch((err) => fail(errMessage(err)));
131
+ }
132
+
133
+ private register(handle: Omit<StoreHandle, 'id' | 'createdAt'>): StoreHandle {
134
+ const full: StoreHandle = { ...handle, id: `store_${this.nextId++}`, createdAt: Date.now() };
135
+ this.stores.set(full.id, full);
136
+ return full;
137
+ }
138
+
139
+ /** k-NN with exact-match metadata filtering applied to an over-fetched result. */
140
+ private async search(h: StoreHandle, vector: number[], k: number, filter?: Metadata) {
141
+ const q = new Float32Array(vector);
142
+ if (!filter) {
143
+ const results = await h.db.query(q, k);
144
+ return { results, scanned: results.length };
145
+ }
146
+ const candidates = [...h.meta.entries.values()].filter((m) => matchesFilter(m, filter)).length;
147
+ if (candidates === 0) return { results: [], scanned: 0 };
148
+ const total = (await h.db.status()).totalVectors;
149
+ let fetch = Math.min(total, Math.max(k * 4, 64));
150
+ for (;;) {
151
+ const raw = await h.db.query(q, fetch);
152
+ const hits = raw.filter((r) => matchesFilter(h.meta.entries.get(r.id), filter));
153
+ if (hits.length >= Math.min(k, candidates) || fetch >= total) {
154
+ return { results: hits.slice(0, k), scanned: raw.length };
155
+ }
156
+ fetch = Math.min(total, fetch * 4);
157
+ }
158
+ }
159
+
75
160
  // ─── Tool Registration ──────────────────────────────────────────────────
76
161
 
77
162
  private registerTools(): void {
78
163
  // ── rvf_create_store ──────────────────────────────────────────────────
79
164
  this.mcp.tool(
80
165
  'rvf_create_store',
81
- 'Create a new RVF vector store at the given path',
166
+ 'Create a new RVF vector store at the given path (relative to the server data dir)',
82
167
  {
83
- path: z.string().describe('File path for the new .rvf store'),
168
+ path: z.string().describe('File path for the new .rvf store, relative to the data dir'),
84
169
  dimensions: z.number().int().positive().max(LIMITS.maxDimensions).describe('Vector dimensionality'),
85
170
  metric: z.enum(['l2', 'cosine', 'dotproduct']).default('l2').describe('Distance metric'),
86
171
  },
87
172
  async ({ path, dimensions, metric }) => {
88
173
  if (this.stores.size >= this.opts.maxStores) {
89
- return { content: [{ type: 'text' as const, text: `Error: max stores (${this.opts.maxStores}) reached` }] };
174
+ return fail(`max stores (${this.opts.maxStores}) reached`);
175
+ }
176
+ try {
177
+ const file = resolveStorePath(this.opts.dataDir, path, true);
178
+ const meta = new MetadataLog(file + META_SUFFIX, false);
179
+ if (fs.existsSync(meta.file)) throw new Error(`${path} already has metadata; use rvf_open_store`);
180
+ const db = await RvfDatabase.create(file, { dimensions, metric: metric as Metric }, 'node');
181
+ meta.header = { dimensions, metric };
182
+ meta.rewrite();
183
+ const h = this.register({ path, file, dimensions, metric, readOnly: false, db, meta });
184
+ return reply({ storeId: h.id, path, dimensions, metric, status: 'created' });
185
+ } catch (err) {
186
+ return fail(errMessage(err));
90
187
  }
91
-
92
- const id = `store_${this.nextId++}`;
93
- const handle: StoreHandle = {
94
- id,
95
- path,
96
- dimensions,
97
- metric,
98
- readOnly: false,
99
- vectors: new Map(),
100
- createdAt: Date.now(),
101
- };
102
- this.stores.set(id, handle);
103
-
104
- return {
105
- content: [{
106
- type: 'text' as const,
107
- text: JSON.stringify({
108
- storeId: id,
109
- path,
110
- dimensions,
111
- metric,
112
- status: 'created',
113
- }, null, 2),
114
- }],
115
- };
116
188
  },
117
189
  );
118
190
 
119
191
  // ── rvf_open_store ────────────────────────────────────────────────────
120
192
  this.mcp.tool(
121
193
  'rvf_open_store',
122
- 'Open an existing RVF store for reading and writing',
194
+ 'Open an existing RVF store (relative to the server data dir) for reading and writing',
123
195
  {
124
- path: z.string().describe('Path to existing .rvf file'),
196
+ path: z.string().describe('Path to existing .rvf file, relative to the data dir'),
125
197
  readOnly: z.boolean().default(false).describe('Open in read-only mode'),
126
198
  },
127
199
  async ({ path, readOnly }) => {
128
200
  if (this.stores.size >= this.opts.maxStores) {
129
- return { content: [{ type: 'text' as const, text: `Error: max stores (${this.opts.maxStores}) reached` }] };
201
+ return fail(`max stores (${this.opts.maxStores}) reached`);
202
+ }
203
+ try {
204
+ const file = resolveStorePath(this.opts.dataDir, path, false);
205
+ if (!fs.existsSync(file)) throw new Error(`store ${path} does not exist`);
206
+ const db = readOnly
207
+ ? await RvfDatabase.openReadonly(file, 'node')
208
+ : await RvfDatabase.open(file, 'node');
209
+ const meta = new MetadataLog(file + META_SUFFIX, readOnly);
210
+ try {
211
+ meta.load();
212
+ } catch (err) {
213
+ await db.close();
214
+ throw err;
215
+ }
216
+ const dimensions = await db.dimension();
217
+ const metric = meta.header?.metric ?? 'l2';
218
+ meta.header ??= { dimensions, metric };
219
+ const h = this.register({ path, file, dimensions, metric, readOnly, db, meta });
220
+ return reply({ storeId: h.id, path, readOnly, dimensions, metric, status: 'opened' });
221
+ } catch (err) {
222
+ return fail(errMessage(err));
130
223
  }
131
-
132
- const id = `store_${this.nextId++}`;
133
- const handle: StoreHandle = {
134
- id,
135
- path,
136
- dimensions: this.opts.defaultDimensions,
137
- metric: 'l2',
138
- readOnly,
139
- vectors: new Map(),
140
- createdAt: Date.now(),
141
- };
142
- this.stores.set(id, handle);
143
-
144
- return {
145
- content: [{
146
- type: 'text' as const,
147
- text: JSON.stringify({
148
- storeId: id,
149
- path,
150
- readOnly,
151
- status: 'opened',
152
- }, null, 2),
153
- }],
154
- };
155
224
  },
156
225
  );
157
226
 
@@ -162,19 +231,11 @@ export class RvfMcpServer {
162
231
  {
163
232
  storeId: z.string().describe('Store ID returned by create/open'),
164
233
  },
165
- async ({ storeId }) => {
166
- const handle = this.stores.get(storeId);
167
- if (!handle) {
168
- return { content: [{ type: 'text' as const, text: `Error: store ${storeId} not found` }] };
169
- }
234
+ async ({ storeId }) => this.withStore(storeId, false, async (h) => {
170
235
  this.stores.delete(storeId);
171
- return {
172
- content: [{
173
- type: 'text' as const,
174
- text: JSON.stringify({ storeId, status: 'closed', path: handle.path }, null, 2),
175
- }],
176
- };
177
- },
236
+ await this.closeHandle(h);
237
+ return reply({ storeId, status: 'closed', path: h.path });
238
+ }),
178
239
  );
179
240
 
180
241
  // ── rvf_ingest ────────────────────────────────────────────────────────
@@ -190,41 +251,42 @@ export class RvfMcpServer {
190
251
  .describe('Optional metadata key-value pairs'),
191
252
  })).max(LIMITS.maxBatch).describe('Vectors to insert'),
192
253
  },
193
- async ({ storeId, entries }) => {
194
- const handle = this.stores.get(storeId);
195
- if (!handle) {
196
- return { content: [{ type: 'text' as const, text: `Error: store ${storeId} not found` }] };
197
- }
198
- if (handle.readOnly) {
199
- return { content: [{ type: 'text' as const, text: 'Error: store is read-only' }] };
200
- }
201
-
202
- let accepted = 0;
254
+ async ({ storeId, entries }) => this.withStore(storeId, true, async (h) => {
255
+ // Last write wins for duplicate ids within a batch (upsert semantics).
256
+ const batch = new Map<string, { vector: number[]; metadata?: Metadata }>();
203
257
  let rejected = 0;
204
-
205
- for (const entry of entries) {
206
- if (entry.vector.length !== handle.dimensions) {
207
- rejected++;
208
- continue;
258
+ for (const e of entries) {
259
+ if (e.vector.length !== h.dimensions) rejected++;
260
+ else batch.set(e.id, { vector: e.vector, metadata: e.metadata });
261
+ }
262
+ if (batch.size > 0) {
263
+ await h.db.ingestBatch([...batch].map(([id, e]) => ({ id, vector: new Float32Array(e.vector) })));
264
+ const sets: Array<[string, Metadata]> = [];
265
+ const cleared: string[] = [];
266
+ for (const [id, e] of batch) {
267
+ if (e.metadata && Object.keys(e.metadata).length > 0) sets.push([id, e.metadata]);
268
+ else cleared.push(id);
209
269
  }
210
- handle.vectors.set(entry.id, {
211
- vector: entry.vector,
212
- metadata: entry.metadata,
213
- });
214
- accepted++;
270
+ h.meta.apply(sets, cleared);
215
271
  }
216
-
217
- return {
218
- content: [{
219
- type: 'text' as const,
220
- text: JSON.stringify({
221
- accepted,
222
- rejected,
223
- totalVectors: handle.vectors.size,
224
- }, null, 2),
225
- }],
226
- };
227
- },
272
+ let status = await h.db.status();
273
+ // rvf-runtime appends a manifest listing every segment on each ingest,
274
+ // so many small ingest calls grow the file quadratically (2,500 single
275
+ // ingests -> 231 MB for 1.3 MB of vectors). Bound it by compacting
276
+ // once the file is both large and mostly overhead. Compaction drops
277
+ // the in-memory HNSW, which is rebuilt lazily on the next query.
278
+ const liveBytes = status.totalVectors * h.dimensions * 4;
279
+ let autoCompacted = false;
280
+ // Stat the file directly: status().fileSizeBytes reads 0 in @ruvector/rvf <= 0.4.2.
281
+ const fileBytes = fs.statSync(h.file).size;
282
+ const { minBytes, ratio } = this.opts.autoCompact;
283
+ if (fileBytes > minBytes && fileBytes > ratio * Math.max(liveBytes, 1)) {
284
+ await h.db.compact();
285
+ status = await h.db.status();
286
+ autoCompacted = true;
287
+ }
288
+ return reply({ accepted: batch.size, rejected, totalVectors: status.totalVectors, autoCompacted });
289
+ }),
228
290
  );
229
291
 
230
292
  // ── rvf_query ─────────────────────────────────────────────────────────
@@ -238,57 +300,13 @@ export class RvfMcpServer {
238
300
  filter: metadataSchema.optional()
239
301
  .describe('Metadata filter (exact match on fields)'),
240
302
  },
241
- async ({ storeId, vector, k, filter }) => {
242
- const handle = this.stores.get(storeId);
243
- if (!handle) {
244
- return { content: [{ type: 'text' as const, text: `Error: store ${storeId} not found` }] };
245
- }
246
-
247
- if (vector.length !== handle.dimensions) {
248
- return {
249
- content: [{
250
- type: 'text' as const,
251
- text: `Error: dimension mismatch (query=${vector.length}, store=${handle.dimensions})`,
252
- }],
253
- };
303
+ async ({ storeId, vector, k, filter }) => this.withStore(storeId, false, async (h) => {
304
+ if (vector.length !== h.dimensions) {
305
+ return fail(`dimension mismatch (query=${vector.length}, store=${h.dimensions})`);
254
306
  }
255
-
256
- // Compute distances and sort
257
- const results: Array<{ id: string; distance: number }> = [];
258
-
259
- for (const [id, entry] of handle.vectors) {
260
- // Apply metadata filter if provided
261
- if (filter && entry.metadata) {
262
- let match = true;
263
- for (const [key, val] of Object.entries(filter)) {
264
- if (entry.metadata[key] !== val) {
265
- match = false;
266
- break;
267
- }
268
- }
269
- if (!match) continue;
270
- } else if (filter && !entry.metadata) {
271
- continue;
272
- }
273
-
274
- const dist = computeDistance(vector, entry.vector, handle.metric);
275
- results.push({ id, distance: dist });
276
- }
277
-
278
- results.sort((a, b) => a.distance - b.distance);
279
- const topK = results.slice(0, k);
280
-
281
- return {
282
- content: [{
283
- type: 'text' as const,
284
- text: JSON.stringify({
285
- results: topK,
286
- totalScanned: handle.vectors.size,
287
- metric: handle.metric,
288
- }, null, 2),
289
- }],
290
- };
291
- },
307
+ const { results, scanned } = await this.search(h, vector, k, filter);
308
+ return reply({ results, totalScanned: scanned, metric: h.metric });
309
+ }),
292
310
  );
293
311
 
294
312
  // ── rvf_delete ────────────────────────────────────────────────────────
@@ -299,27 +317,12 @@ export class RvfMcpServer {
299
317
  storeId: z.string().describe('Store ID'),
300
318
  ids: z.array(z.string()).max(LIMITS.maxBatch).describe('Vector IDs to delete'),
301
319
  },
302
- async ({ storeId, ids }) => {
303
- const handle = this.stores.get(storeId);
304
- if (!handle) {
305
- return { content: [{ type: 'text' as const, text: `Error: store ${storeId} not found` }] };
306
- }
307
- if (handle.readOnly) {
308
- return { content: [{ type: 'text' as const, text: 'Error: store is read-only' }] };
309
- }
310
-
311
- let deleted = 0;
312
- for (const id of ids) {
313
- if (handle.vectors.delete(id)) deleted++;
314
- }
315
-
316
- return {
317
- content: [{
318
- type: 'text' as const,
319
- text: JSON.stringify({ deleted, remaining: handle.vectors.size }, null, 2),
320
- }],
321
- };
322
- },
320
+ async ({ storeId, ids }) => this.withStore(storeId, true, async (h) => {
321
+ const { deleted } = await h.db.delete(ids);
322
+ h.meta.apply([], ids);
323
+ const status = await h.db.status();
324
+ return reply({ deleted, remaining: status.totalVectors });
325
+ }),
323
326
  );
324
327
 
325
328
  // ── rvf_delete_filter ─────────────────────────────────────────────────
@@ -331,38 +334,13 @@ export class RvfMcpServer {
331
334
  filter: metadataSchema
332
335
  .describe('Metadata filter — all matching vectors will be deleted'),
333
336
  },
334
- async ({ storeId, filter }) => {
335
- const handle = this.stores.get(storeId);
336
- if (!handle) {
337
- return { content: [{ type: 'text' as const, text: `Error: store ${storeId} not found` }] };
338
- }
339
- if (handle.readOnly) {
340
- return { content: [{ type: 'text' as const, text: 'Error: store is read-only' }] };
341
- }
342
-
343
- let deleted = 0;
344
- for (const [id, entry] of handle.vectors) {
345
- if (!entry.metadata) continue;
346
- let match = true;
347
- for (const [key, val] of Object.entries(filter)) {
348
- if (entry.metadata[key] !== val) {
349
- match = false;
350
- break;
351
- }
352
- }
353
- if (match) {
354
- handle.vectors.delete(id);
355
- deleted++;
356
- }
357
- }
358
-
359
- return {
360
- content: [{
361
- type: 'text' as const,
362
- text: JSON.stringify({ deleted, remaining: handle.vectors.size }, null, 2),
363
- }],
364
- };
365
- },
337
+ async ({ storeId, filter }) => this.withStore(storeId, true, async (h) => {
338
+ const ids = [...h.meta.entries].filter(([, m]) => matchesFilter(m, filter)).map(([id]) => id);
339
+ const deleted = ids.length > 0 ? (await h.db.delete(ids)).deleted : 0;
340
+ h.meta.apply([], ids);
341
+ const status = await h.db.status();
342
+ return reply({ deleted, remaining: status.totalVectors });
343
+ }),
366
344
  );
367
345
 
368
346
  // ── rvf_compact ───────────────────────────────────────────────────────
@@ -372,23 +350,18 @@ export class RvfMcpServer {
372
350
  {
373
351
  storeId: z.string().describe('Store ID'),
374
352
  },
375
- async ({ storeId }) => {
376
- const handle = this.stores.get(storeId);
377
- if (!handle) {
378
- return { content: [{ type: 'text' as const, text: `Error: store ${storeId} not found` }] };
379
- }
380
-
381
- return {
382
- content: [{
383
- type: 'text' as const,
384
- text: JSON.stringify({
385
- storeId,
386
- compacted: true,
387
- totalVectors: handle.vectors.size,
388
- }, null, 2),
389
- }],
390
- };
391
- },
353
+ async ({ storeId }) => this.withStore(storeId, true, async (h) => {
354
+ const result = await h.db.compact();
355
+ h.meta.rewrite();
356
+ const status = await h.db.status();
357
+ return reply({
358
+ storeId,
359
+ compacted: true,
360
+ segmentsCompacted: result.segmentsCompacted,
361
+ bytesReclaimed: result.bytesReclaimed,
362
+ totalVectors: status.totalVectors,
363
+ });
364
+ }),
392
365
  );
393
366
 
394
367
  // ── rvf_status ────────────────────────────────────────────────────────
@@ -398,27 +371,21 @@ export class RvfMcpServer {
398
371
  {
399
372
  storeId: z.string().describe('Store ID'),
400
373
  },
401
- async ({ storeId }) => {
402
- const handle = this.stores.get(storeId);
403
- if (!handle) {
404
- return { content: [{ type: 'text' as const, text: `Error: store ${storeId} not found` }] };
405
- }
406
-
407
- return {
408
- content: [{
409
- type: 'text' as const,
410
- text: JSON.stringify({
411
- storeId: handle.id,
412
- path: handle.path,
413
- dimensions: handle.dimensions,
414
- metric: handle.metric,
415
- readOnly: handle.readOnly,
416
- totalVectors: handle.vectors.size,
417
- createdAt: new Date(handle.createdAt).toISOString(),
418
- }, null, 2),
419
- }],
420
- };
421
- },
374
+ async ({ storeId }) => this.withStore(storeId, false, async (h) => {
375
+ const status = await h.db.status();
376
+ return reply({
377
+ storeId: h.id,
378
+ path: h.path,
379
+ dimensions: h.dimensions,
380
+ metric: h.metric,
381
+ readOnly: h.readOnly,
382
+ totalVectors: status.totalVectors,
383
+ totalSegments: status.totalSegments,
384
+ fileSizeBytes: status.fileSizeBytes,
385
+ epoch: status.epoch,
386
+ createdAt: new Date(h.createdAt).toISOString(),
387
+ });
388
+ }),
422
389
  );
423
390
 
424
391
  // ── rvf_list_stores ───────────────────────────────────────────────────
@@ -427,25 +394,51 @@ export class RvfMcpServer {
427
394
  'List all open RVF stores',
428
395
  {},
429
396
  async () => {
430
- const list = Array.from(this.stores.values()).map((h) => ({
431
- storeId: h.id,
432
- path: h.path,
433
- dimensions: h.dimensions,
434
- metric: h.metric,
435
- totalVectors: h.vectors.size,
436
- readOnly: h.readOnly,
437
- }));
438
-
439
- return {
440
- content: [{
441
- type: 'text' as const,
442
- text: JSON.stringify({ stores: list, count: list.length }, null, 2),
443
- }],
444
- };
397
+ const list = await this.listStores();
398
+ return reply({
399
+ stores: list.map((h) => ({
400
+ storeId: h.storeId,
401
+ path: h.path,
402
+ dimensions: h.dimensions,
403
+ metric: h.metric,
404
+ totalVectors: h.totalVectors,
405
+ readOnly: h.readOnly,
406
+ })),
407
+ count: list.length,
408
+ });
445
409
  },
446
410
  );
447
411
  }
448
412
 
413
+ private async listStores() {
414
+ const out = [];
415
+ for (const h of this.stores.values()) {
416
+ let totalVectors = 0;
417
+ try {
418
+ totalVectors = (await h.db.status()).totalVectors;
419
+ } catch {
420
+ // report the store even if its status call fails
421
+ }
422
+ out.push({
423
+ storeId: h.id,
424
+ path: h.path,
425
+ dimensions: h.dimensions,
426
+ metric: h.metric,
427
+ readOnly: h.readOnly,
428
+ totalVectors,
429
+ });
430
+ }
431
+ return out;
432
+ }
433
+
434
+ private async closeHandle(h: StoreHandle): Promise<void> {
435
+ try {
436
+ h.meta.rewrite();
437
+ } finally {
438
+ await h.db.close();
439
+ }
440
+ }
441
+
449
442
  // ─── Resource Registration ──────────────────────────────────────────────
450
443
 
451
444
  private registerResources(): void {
@@ -455,11 +448,11 @@ export class RvfMcpServer {
455
448
  'rvf://stores',
456
449
  { description: 'List all open RVF stores and their status' },
457
450
  async () => {
458
- const list = Array.from(this.stores.values()).map((h) => ({
459
- storeId: h.id,
451
+ const list = (await this.listStores()).map((h) => ({
452
+ storeId: h.storeId,
460
453
  path: h.path,
461
454
  dimensions: h.dimensions,
462
- totalVectors: h.vectors.size,
455
+ totalVectors: h.totalVectors,
463
456
  }));
464
457
 
465
458
  return {
@@ -523,56 +516,23 @@ export class RvfMcpServer {
523
516
  }
524
517
 
525
518
  async close(): Promise<void> {
526
- // Close all stores
519
+ // Close every store so native writer locks are released and metadata
520
+ // logs are compacted before the transport goes away.
521
+ const handles = [...this.stores.values()];
527
522
  this.stores.clear();
523
+ let failure: unknown;
524
+ for (const h of handles) {
525
+ try {
526
+ await this.closeHandle(h);
527
+ } catch (err) {
528
+ failure ??= err;
529
+ }
530
+ }
528
531
  await this.mcp.close();
532
+ if (failure) throw failure;
529
533
  }
530
534
 
531
535
  get storeCount(): number {
532
536
  return this.stores.size;
533
537
  }
534
538
  }
535
-
536
- // ─── Distance Functions ─────────────────────────────────────────────────────
537
-
538
- function computeDistance(a: number[], b: number[], metric: string): number {
539
- switch (metric) {
540
- case 'cosine':
541
- return cosineDistance(a, b);
542
- case 'dotproduct':
543
- return -dotProduct(a, b);
544
- default: // l2
545
- return l2Distance(a, b);
546
- }
547
- }
548
-
549
- function l2Distance(a: number[], b: number[]): number {
550
- let sum = 0;
551
- for (let i = 0; i < a.length; i++) {
552
- const d = a[i] - b[i];
553
- sum += d * d;
554
- }
555
- return sum;
556
- }
557
-
558
- function dotProduct(a: number[], b: number[]): number {
559
- let sum = 0;
560
- for (let i = 0; i < a.length; i++) {
561
- sum += a[i] * b[i];
562
- }
563
- return sum;
564
- }
565
-
566
- function cosineDistance(a: number[], b: number[]): number {
567
- let dot = 0;
568
- let normA = 0;
569
- let normB = 0;
570
- for (let i = 0; i < a.length; i++) {
571
- dot += a[i] * b[i];
572
- normA += a[i] * a[i];
573
- normB += b[i] * b[i];
574
- }
575
- const denom = Math.sqrt(normA) * Math.sqrt(normB);
576
- if (denom === 0) return 1;
577
- return 1 - dot / denom;
578
- }