@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/README.md +3 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +23 -12
- package/dist/cli.js.map +1 -1
- package/dist/server.d.ts +27 -1
- package/dist/server.js +242 -279
- package/dist/server.js.map +1 -1
- package/dist/stores.d.ts +46 -0
- package/dist/stores.js +196 -0
- package/dist/stores.js.map +1 -0
- package/package.json +2 -1
- package/src/cli.ts +23 -14
- package/src/server.ts +265 -305
- package/src/stores.ts +192 -0
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
vector: entry.vector,
|
|
212
|
-
metadata: entry.metadata,
|
|
213
|
-
});
|
|
214
|
-
accepted++;
|
|
270
|
+
h.meta.apply(sets, cleared);
|
|
215
271
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 = (await fs.promises.stat(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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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 =
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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 =
|
|
459
|
-
storeId: h.
|
|
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.
|
|
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
|
|
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
|
-
}
|