@reddb-io/ui-mcp 0.1.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/LICENSE +17 -0
- package/dist/connection.d.ts +20 -0
- package/dist/connection.js +61 -0
- package/dist/connection.js.map +1 -0
- package/dist/data-tools.d.ts +51 -0
- package/dist/data-tools.js +285 -0
- package/dist/data-tools.js.map +1 -0
- package/dist/embed-server.d.ts +40 -0
- package/dist/embed-server.js +280 -0
- package/dist/embed-server.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +599 -0
- package/dist/index.js.map +1 -0
- package/dist/local-server.d.ts +62 -0
- package/dist/local-server.js +356 -0
- package/dist/local-server.js.map +1 -0
- package/dist/red-client.d.ts +72 -0
- package/dist/red-client.js +171 -0
- package/dist/red-client.js.map +1 -0
- package/dist/target-mode.d.ts +10 -0
- package/dist/target-mode.js +39 -0
- package/dist/target-mode.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Local-file mode for the MCP App (#48, #50, ADR-0006).
|
|
2
|
+
//
|
|
3
|
+
// A browser Surface cannot reach a filesystem `.rdb`, and the MCP process (Node)
|
|
4
|
+
// cannot open it either — reddb's embedded mode is Rust-only. So for a local
|
|
5
|
+
// target we spawn ONE `red server --http --http-bind 127.0.0.1:<ephemeral>
|
|
6
|
+
// --path <file>` child that owns the file, wait for `/stats` to report healthy,
|
|
7
|
+
// and hand the UI `?cs=http://127.0.0.1:<port>`. The child is torn down on MCP
|
|
8
|
+
// exit/disconnect — no orphan servers. We never reimplement the reddb HTTP API;
|
|
9
|
+
// we shell out to the `red` binary.
|
|
10
|
+
//
|
|
11
|
+
// Lock-aware open (#50): reddb takes a single-writer `flock` on the file. The
|
|
12
|
+
// trigger case is inspecting an agent's live memory `.rdb` while the agent is
|
|
13
|
+
// writing it — a read-write open would fail on the lock. So when the caller does
|
|
14
|
+
// not pin `readOnly`, we open read-write first and, if reddb dies on the lock,
|
|
15
|
+
// transparently retry with `--read-only`. reddb then reports `read_only` in
|
|
16
|
+
// `/stats` and the UI renders the existing read-only badge (#23).
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { accessSync, constants as fsConstants } from "node:fs";
|
|
19
|
+
import { createServer } from "node:net";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
// Modules that may expose reddb's binary resolver. `@reddb-io/sdk` is the
|
|
24
|
+
// canonical name in ADR-0006; `reddb-cli` is the package that currently ships
|
|
25
|
+
// `resolveBinary`. Either is optional — neither is a hard dependency of this
|
|
26
|
+
// package, so resolution falls through to a PATH `red` when absent.
|
|
27
|
+
const SDK_CANDIDATES = ["@reddb-io/sdk", "reddb-cli"];
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a fuzzy local target into an absolute filesystem path the `red`
|
|
30
|
+
* binary can `--path`. Handles `file://` URLs, `~`/`~/…` home expansion, and
|
|
31
|
+
* relative paths (resolved against the MCP process cwd).
|
|
32
|
+
*/
|
|
33
|
+
export function resolveLocalFilePath(target) {
|
|
34
|
+
const t = target.trim();
|
|
35
|
+
if (/^file:\/\//i.test(t))
|
|
36
|
+
return fileURLToPath(t);
|
|
37
|
+
if (t === "~")
|
|
38
|
+
return homedir();
|
|
39
|
+
if (t.startsWith("~/"))
|
|
40
|
+
return join(homedir(), t.slice(2));
|
|
41
|
+
if (isAbsolute(t))
|
|
42
|
+
return t;
|
|
43
|
+
return resolve(process.cwd(), t);
|
|
44
|
+
}
|
|
45
|
+
function isExecutable(file) {
|
|
46
|
+
try {
|
|
47
|
+
accessSync(file, fsConstants.X_OK);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Find an executable by name on PATH (mirrors `which`). */
|
|
55
|
+
function findOnPath(name) {
|
|
56
|
+
const paths = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
57
|
+
const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
|
|
58
|
+
for (const dir of paths) {
|
|
59
|
+
for (const candidate of names) {
|
|
60
|
+
const full = join(dir, candidate);
|
|
61
|
+
if (process.platform === "win32" ? true : isExecutable(full)) {
|
|
62
|
+
// On win32 PATHEXT handling is approximate; accept the first match.
|
|
63
|
+
if (process.platform === "win32" && !isFile(full))
|
|
64
|
+
continue;
|
|
65
|
+
return full;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function isFile(file) {
|
|
72
|
+
try {
|
|
73
|
+
accessSync(file, fsConstants.F_OK);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the `red` binary: explicit `RED_BINARY` env → `@reddb-io/sdk`'s
|
|
82
|
+
* subprocess binary (or `reddb-cli`'s) → a PATH `red`. Throws an actionable
|
|
83
|
+
* error when none is found.
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveRedBinary() {
|
|
86
|
+
const envBin = process.env.RED_BINARY?.trim();
|
|
87
|
+
if (envBin)
|
|
88
|
+
return { binaryPath: envBin, source: "env" };
|
|
89
|
+
for (const name of SDK_CANDIDATES) {
|
|
90
|
+
try {
|
|
91
|
+
const mod = await import(name);
|
|
92
|
+
const resolveBinary = (mod.resolveBinary ??
|
|
93
|
+
mod.default?.resolveBinary);
|
|
94
|
+
if (typeof resolveBinary === "function") {
|
|
95
|
+
const binaryPath = await resolveBinary();
|
|
96
|
+
if (binaryPath && isFile(binaryPath)) {
|
|
97
|
+
return { binaryPath, source: name };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not installed or resolution failed; try the next strategy.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const onPath = findOnPath("red");
|
|
106
|
+
if (onPath)
|
|
107
|
+
return { binaryPath: onPath, source: "path" };
|
|
108
|
+
throw new Error("Cannot open a local .rdb file: no reddb `red` binary found. " +
|
|
109
|
+
"Install the reddb SDK (`pnpm add @reddb-io/sdk`) so its bundled binary " +
|
|
110
|
+
"can be used, put `red` on your PATH, or set RED_BINARY to its absolute path. " +
|
|
111
|
+
"See ADR-0006.");
|
|
112
|
+
}
|
|
113
|
+
/** Reserve a free TCP port on the loopback interface, then release it. */
|
|
114
|
+
function pickEphemeralPort() {
|
|
115
|
+
return new Promise((resolvePort, reject) => {
|
|
116
|
+
const srv = createServer();
|
|
117
|
+
srv.unref();
|
|
118
|
+
srv.once("error", reject);
|
|
119
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
120
|
+
const addr = srv.address();
|
|
121
|
+
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
122
|
+
srv.close(() => {
|
|
123
|
+
if (port)
|
|
124
|
+
resolvePort(port);
|
|
125
|
+
else
|
|
126
|
+
reject(new Error("Could not reserve an ephemeral port."));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function delay(ms) {
|
|
132
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Poll `${baseUrl}/stats` until it answers 200, the child exits, the timeout
|
|
136
|
+
* elapses, or the signal aborts. `/stats` is reddb's canonical proof-of-life
|
|
137
|
+
* (CLAUDE.md: `/health` returns 503 by design).
|
|
138
|
+
*/
|
|
139
|
+
async function waitForHealthy(baseUrl, child, timeoutMs, getStderr, signal) {
|
|
140
|
+
const deadline = Date.now() + timeoutMs;
|
|
141
|
+
for (;;) {
|
|
142
|
+
if (signal?.aborted)
|
|
143
|
+
throw new Error("Local server startup aborted.");
|
|
144
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
145
|
+
throw new Error(`red server exited (code ${child.exitCode}, signal ${child.signalCode}) ` +
|
|
146
|
+
`before /stats became healthy.${getStderr() ? `\n${getStderr()}` : ""}`);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(`${baseUrl}/stats`, {
|
|
150
|
+
signal: AbortSignal.timeout(1500),
|
|
151
|
+
});
|
|
152
|
+
if (res.ok)
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Not accepting connections yet — keep polling.
|
|
157
|
+
}
|
|
158
|
+
if (Date.now() > deadline) {
|
|
159
|
+
throw new Error(`red server did not report healthy at ${baseUrl}/stats within ` +
|
|
160
|
+
`${timeoutMs}ms.${getStderr() ? `\n${getStderr()}` : ""}`);
|
|
161
|
+
}
|
|
162
|
+
await delay(150);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Raised when `red server` dies trying to acquire the single-writer `flock` on
|
|
167
|
+
* the target — the signal to retry the open with `--read-only` (#50).
|
|
168
|
+
*/
|
|
169
|
+
export class LockContentionError extends Error {
|
|
170
|
+
detail;
|
|
171
|
+
constructor(detail) {
|
|
172
|
+
super("The .rdb is already held by another writer (reddb single-writer flock)." +
|
|
173
|
+
(detail ? `\n${detail}` : ""));
|
|
174
|
+
this.detail = detail;
|
|
175
|
+
this.name = "LockContentionError";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Signatures of reddb failing to take the single-writer `flock` on the file,
|
|
179
|
+
// across platforms and wordings. Deliberately broad: a false positive only costs
|
|
180
|
+
// one extra `--read-only` retry, while a miss leaves the trigger case (a live
|
|
181
|
+
// agent memory `.rdb`) unopenable. We cannot import reddb's exact message —
|
|
182
|
+
// it is a separate Rust binary — so we match the family of lock-contention text.
|
|
183
|
+
const LOCK_CONTENTION_PATTERNS = [
|
|
184
|
+
/flock/i,
|
|
185
|
+
/\block(ed|ing)?\b/i,
|
|
186
|
+
/would.?block/i,
|
|
187
|
+
/resource temporarily unavailable/i,
|
|
188
|
+
/\bE?WOULDBLOCK\b|\bEAGAIN\b/i,
|
|
189
|
+
/another (process|writer|instance)/i,
|
|
190
|
+
/already (open|held|locked|running)/i,
|
|
191
|
+
/single.?writer/i,
|
|
192
|
+
/in use by/i,
|
|
193
|
+
/read[-_ ]?only/i,
|
|
194
|
+
];
|
|
195
|
+
/** True when `text` (a `red server` stderr) looks like single-writer lock
|
|
196
|
+
* contention — used to decide whether to retry the open as `--read-only`. */
|
|
197
|
+
export function looksLikeLockContention(text) {
|
|
198
|
+
return Boolean(text) && LOCK_CONTENTION_PATTERNS.some((re) => re.test(text));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Spawn one `red server` for a single open mode and wait for `/stats` healthy.
|
|
202
|
+
* Resolves to a tracked-elsewhere handle, or rejects: with `LockContentionError`
|
|
203
|
+
* when the child died on the flock (caller may retry `--read-only`), otherwise
|
|
204
|
+
* with the raw startup error.
|
|
205
|
+
*/
|
|
206
|
+
async function attemptSpawn(opts) {
|
|
207
|
+
const port = opts.port ?? (await pickEphemeralPort());
|
|
208
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
209
|
+
const args = [
|
|
210
|
+
"server",
|
|
211
|
+
"--http",
|
|
212
|
+
"--http-bind",
|
|
213
|
+
`127.0.0.1:${port}`,
|
|
214
|
+
"--path",
|
|
215
|
+
opts.filePath,
|
|
216
|
+
];
|
|
217
|
+
if (opts.readOnly)
|
|
218
|
+
args.push("--read-only");
|
|
219
|
+
const child = spawn(opts.binaryPath, args, {
|
|
220
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
221
|
+
});
|
|
222
|
+
let stderr = "";
|
|
223
|
+
child.stderr?.on("data", (chunk) => {
|
|
224
|
+
stderr += chunk.toString();
|
|
225
|
+
if (stderr.length > 4000)
|
|
226
|
+
stderr = stderr.slice(-4000);
|
|
227
|
+
});
|
|
228
|
+
const handle = {
|
|
229
|
+
baseUrl,
|
|
230
|
+
port,
|
|
231
|
+
filePath: opts.filePath,
|
|
232
|
+
readOnly: opts.readOnly,
|
|
233
|
+
child,
|
|
234
|
+
stop: makeStop(child),
|
|
235
|
+
};
|
|
236
|
+
try {
|
|
237
|
+
await waitForHealthy(baseUrl, child, opts.healthTimeoutMs, () => stderr.trim(), opts.signal);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
await handle.stop();
|
|
241
|
+
// Did the child die on the single-writer flock? If so, let the caller decide
|
|
242
|
+
// to retry --read-only rather than surfacing a raw "exited" error.
|
|
243
|
+
const exited = child.exitCode !== null || child.signalCode !== null;
|
|
244
|
+
if (!opts.readOnly && exited && looksLikeLockContention(stderr)) {
|
|
245
|
+
throw new LockContentionError(stderr.trim());
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
return handle;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Spawn one `red server` that owns `target`, wait for `/stats` to report
|
|
253
|
+
* healthy, and return a handle whose `stop()` tears the child down.
|
|
254
|
+
*
|
|
255
|
+
* Open mode (#50): an explicit `readOnly` is honoured verbatim. When it is
|
|
256
|
+
* unspecified, the file opens read-write but transparently falls back to
|
|
257
|
+
* `--read-only` if reddb dies acquiring the single-writer `flock` — the trigger
|
|
258
|
+
* case being a second viewer of a live agent memory `.rdb` (ADR-0006).
|
|
259
|
+
*/
|
|
260
|
+
export async function spawnLocalServer(options) {
|
|
261
|
+
const { target, healthTimeoutMs = 10_000, signal } = options;
|
|
262
|
+
const filePath = resolveLocalFilePath(target);
|
|
263
|
+
const binaryPath = options.binaryPath ?? (await resolveRedBinary()).binaryPath;
|
|
264
|
+
const attempt = (readOnly) => attemptSpawn({
|
|
265
|
+
binaryPath,
|
|
266
|
+
filePath,
|
|
267
|
+
readOnly,
|
|
268
|
+
port: options.port,
|
|
269
|
+
healthTimeoutMs,
|
|
270
|
+
signal,
|
|
271
|
+
});
|
|
272
|
+
let handle;
|
|
273
|
+
if (options.readOnly !== undefined) {
|
|
274
|
+
handle = await attempt(options.readOnly);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
try {
|
|
278
|
+
handle = await attempt(false);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!(error instanceof LockContentionError))
|
|
282
|
+
throw error;
|
|
283
|
+
// The file is flock-held by another writer — reopen read-only (#50).
|
|
284
|
+
handle = await attempt(true);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
trackServer(handle);
|
|
288
|
+
return handle;
|
|
289
|
+
}
|
|
290
|
+
function makeStop(child) {
|
|
291
|
+
let stopping = null;
|
|
292
|
+
return () => {
|
|
293
|
+
if (stopping)
|
|
294
|
+
return stopping;
|
|
295
|
+
stopping = new Promise((resolveStop) => {
|
|
296
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
297
|
+
resolveStop();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const onExit = () => {
|
|
301
|
+
clearTimeout(killTimer);
|
|
302
|
+
resolveStop();
|
|
303
|
+
};
|
|
304
|
+
child.once("exit", onExit);
|
|
305
|
+
const killTimer = setTimeout(() => {
|
|
306
|
+
child.kill("SIGKILL");
|
|
307
|
+
}, 3000);
|
|
308
|
+
killTimer.unref?.();
|
|
309
|
+
child.kill("SIGTERM");
|
|
310
|
+
});
|
|
311
|
+
return stopping;
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// --- lifecycle registry: guarantee no orphan servers on MCP exit/disconnect ---
|
|
315
|
+
const liveServers = new Set();
|
|
316
|
+
let exitHooksInstalled = false;
|
|
317
|
+
function installExitHooks() {
|
|
318
|
+
if (exitHooksInstalled)
|
|
319
|
+
return;
|
|
320
|
+
exitHooksInstalled = true;
|
|
321
|
+
// Synchronous best-effort kill on hard exit — handlers can't await.
|
|
322
|
+
process.on("exit", () => {
|
|
323
|
+
for (const handle of liveServers) {
|
|
324
|
+
try {
|
|
325
|
+
handle.child.kill("SIGKILL");
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// already gone
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
333
|
+
process.on(sig, () => {
|
|
334
|
+
for (const handle of liveServers) {
|
|
335
|
+
try {
|
|
336
|
+
handle.child.kill("SIGKILL");
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// already gone
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
process.exit(0);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function trackServer(handle) {
|
|
347
|
+
installExitHooks();
|
|
348
|
+
liveServers.add(handle);
|
|
349
|
+
handle.child.once("exit", () => liveServers.delete(handle));
|
|
350
|
+
}
|
|
351
|
+
/** Stop every tracked local server (MCP transport close / shutdown). */
|
|
352
|
+
export async function stopAllLocalServers() {
|
|
353
|
+
const handles = [...liveServers];
|
|
354
|
+
await Promise.all(handles.map((h) => h.stop()));
|
|
355
|
+
}
|
|
356
|
+
//# sourceMappingURL=local-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-server.js","sourceRoot":"","sources":["../src/local-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface QueryRow {
|
|
2
|
+
values: Record<string, unknown>;
|
|
3
|
+
edges?: Record<string, unknown>;
|
|
4
|
+
nodes?: Record<string, unknown>;
|
|
5
|
+
paths?: unknown[];
|
|
6
|
+
}
|
|
7
|
+
export interface QueryResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
query: string;
|
|
10
|
+
capability?: string;
|
|
11
|
+
engine?: string;
|
|
12
|
+
mode?: string;
|
|
13
|
+
record_count: number;
|
|
14
|
+
result: {
|
|
15
|
+
columns: string[];
|
|
16
|
+
records: QueryRow[];
|
|
17
|
+
};
|
|
18
|
+
error?: string;
|
|
19
|
+
/** True when assembled from the NDJSON stream rather than buffered `/query`. */
|
|
20
|
+
streamed?: boolean;
|
|
21
|
+
/** True when streaming stopped at `maxRows` with more rows on the server. */
|
|
22
|
+
truncated?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Result of collecting a read-only SELECT over the NDJSON streaming endpoint. */
|
|
25
|
+
export interface StreamedQueryResult {
|
|
26
|
+
columns: string[];
|
|
27
|
+
schemaFingerprint?: string;
|
|
28
|
+
rows: Array<Record<string, unknown>>;
|
|
29
|
+
rowCount: number;
|
|
30
|
+
truncated: boolean;
|
|
31
|
+
cursor?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface RedClientOptions {
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
}
|
|
37
|
+
export declare class RedClient {
|
|
38
|
+
readonly baseUrl: string;
|
|
39
|
+
private readonly headers;
|
|
40
|
+
private readonly fetcher;
|
|
41
|
+
constructor(baseUrl: string, opts?: RedClientOptions);
|
|
42
|
+
private json;
|
|
43
|
+
/** Buffered query (`POST /query`). */
|
|
44
|
+
query(query: string): Promise<QueryResult>;
|
|
45
|
+
collections(): Promise<string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Stream a read-only SELECT through `POST /query/stream` (NDJSON, chunked),
|
|
48
|
+
* collecting up to `maxRows` before cancelling the server cursor. Throws for a
|
|
49
|
+
* non-SELECT (server `400`) and for an absent route on an older server (`404`)
|
|
50
|
+
* — callers fall back to {@link query}.
|
|
51
|
+
*/
|
|
52
|
+
queryStreamCollect(query: string, opts?: {
|
|
53
|
+
maxRows?: number;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}): Promise<StreamedQueryResult>;
|
|
56
|
+
/** Cancel a streaming-query cursor server-side (`POST /query/stream/cancel`). */
|
|
57
|
+
cancelQueryStream(cursor: string): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
/** A read-only client surface — the only methods the data tools depend on. So
|
|
60
|
+
* tests can pass a mock, and #48's local-spawn path can supply its own. */
|
|
61
|
+
export type ReadClient = Pick<RedClient, "query" | "collections" | "queryStreamCollect">;
|
|
62
|
+
/**
|
|
63
|
+
* Run a read SELECT preferring the bounded-memory NDJSON stream, falling back to
|
|
64
|
+
* the buffered `POST /query` for everything the stream can't take — a non-SELECT
|
|
65
|
+
* statement, a network error, or an older server with no `/query/stream` route.
|
|
66
|
+
* Honest streaming negotiation (#22): identical to {@link RedClient.query}
|
|
67
|
+
* unless the server supports streaming AND the statement is a SELECT.
|
|
68
|
+
*/
|
|
69
|
+
export declare function runQueryPreferStream(client: ReadClient, sql: string, opts?: {
|
|
70
|
+
maxRows?: number;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
}): Promise<QueryResult>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Server-side reddb HTTP client for the MCP process (#49, ADR-0006).
|
|
2
|
+
//
|
|
3
|
+
// The MCP App's data tools (`query`/`list`/`get`) answer the *model* directly —
|
|
4
|
+
// they run here in the Node process against the resolved connection, rather than
|
|
5
|
+
// driving the visual iframe. This is a focused port of the browser client in
|
|
6
|
+
// `packages/ui/src/lib/reddb/client.ts`: it covers only the read endpoints the
|
|
7
|
+
// data tools need (`/query`, `/query/stream`, `/collections`) and reuses Node's
|
|
8
|
+
// global `fetch` (Node >= 20). It is NOT @reddb-io/client — endpoints mirror the
|
|
9
|
+
// hand-rolled UI client, verified against reddb's routing.rs.
|
|
10
|
+
//
|
|
11
|
+
// Auth lives entirely in the `headers` passed to the constructor; it is never
|
|
12
|
+
// logged and never returned in tool output (acceptance: no secret/token leaks).
|
|
13
|
+
/**
|
|
14
|
+
* Parse a chunked `application/x-ndjson` body into one frame per line. Buffers
|
|
15
|
+
* across chunk boundaries and flushes a trailing newline-less line at EOF.
|
|
16
|
+
*/
|
|
17
|
+
async function* parseNdjsonFrames(body) {
|
|
18
|
+
const reader = body.getReader();
|
|
19
|
+
const decoder = new TextDecoder();
|
|
20
|
+
let buf = "";
|
|
21
|
+
try {
|
|
22
|
+
for (;;) {
|
|
23
|
+
const { done, value } = await reader.read();
|
|
24
|
+
if (done)
|
|
25
|
+
break;
|
|
26
|
+
buf += decoder.decode(value, { stream: true });
|
|
27
|
+
let nl;
|
|
28
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
29
|
+
const line = buf.slice(0, nl).trim();
|
|
30
|
+
buf = buf.slice(nl + 1);
|
|
31
|
+
if (line)
|
|
32
|
+
yield JSON.parse(line);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const tail = buf.trim();
|
|
36
|
+
if (tail)
|
|
37
|
+
yield JSON.parse(tail);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
reader.releaseLock();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const SELECT_RE = /^\s*select\b/i;
|
|
44
|
+
export class RedClient {
|
|
45
|
+
baseUrl;
|
|
46
|
+
headers;
|
|
47
|
+
fetcher;
|
|
48
|
+
constructor(baseUrl, opts = {}) {
|
|
49
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
50
|
+
this.headers = opts.headers;
|
|
51
|
+
this.fetcher = opts.fetch ?? fetch;
|
|
52
|
+
}
|
|
53
|
+
async json(path, init) {
|
|
54
|
+
const url = `${this.baseUrl}${path}`;
|
|
55
|
+
const headers = {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
...this.headers,
|
|
58
|
+
...init?.headers,
|
|
59
|
+
};
|
|
60
|
+
const res = await this.fetcher(url, { ...init, headers });
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const body = await res.text().catch(() => "");
|
|
63
|
+
// The error body is the server's response, not our request — it never
|
|
64
|
+
// contains the auth header we sent. Truncated to keep tool output terse.
|
|
65
|
+
throw new Error(`${init?.method ?? "GET"} ${path} → ${res.status} ${body.slice(0, 200)}`);
|
|
66
|
+
}
|
|
67
|
+
return res.json();
|
|
68
|
+
}
|
|
69
|
+
/** Buffered query (`POST /query`). */
|
|
70
|
+
async query(query) {
|
|
71
|
+
return this.json("/query", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
body: JSON.stringify({ query }),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async collections() {
|
|
77
|
+
const r = await this.json("/collections");
|
|
78
|
+
return r.collections ?? [];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Stream a read-only SELECT through `POST /query/stream` (NDJSON, chunked),
|
|
82
|
+
* collecting up to `maxRows` before cancelling the server cursor. Throws for a
|
|
83
|
+
* non-SELECT (server `400`) and for an absent route on an older server (`404`)
|
|
84
|
+
* — callers fall back to {@link query}.
|
|
85
|
+
*/
|
|
86
|
+
async queryStreamCollect(query, opts = {}) {
|
|
87
|
+
const maxRows = opts.maxRows ?? 10_000;
|
|
88
|
+
const res = await this.fetcher(`${this.baseUrl}/query/stream`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: {
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
Accept: "application/x-ndjson",
|
|
93
|
+
...this.headers,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({ query }),
|
|
96
|
+
signal: opts.signal,
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok || !res.body) {
|
|
99
|
+
const body = res.body ? await res.text().catch(() => "") : "";
|
|
100
|
+
throw new Error(`POST /query/stream → ${res.status} ${body.slice(0, 200)}`);
|
|
101
|
+
}
|
|
102
|
+
const out = {
|
|
103
|
+
columns: [],
|
|
104
|
+
rows: [],
|
|
105
|
+
rowCount: 0,
|
|
106
|
+
truncated: false,
|
|
107
|
+
};
|
|
108
|
+
for await (const frame of parseNdjsonFrames(res.body)) {
|
|
109
|
+
if ("descriptor" in frame) {
|
|
110
|
+
out.columns = frame.descriptor.columns ?? [];
|
|
111
|
+
out.schemaFingerprint = frame.descriptor.schema_fingerprint;
|
|
112
|
+
}
|
|
113
|
+
else if ("cursor" in frame) {
|
|
114
|
+
out.cursor = frame.cursor.token;
|
|
115
|
+
}
|
|
116
|
+
else if ("row" in frame) {
|
|
117
|
+
if (out.rows.length >= maxRows) {
|
|
118
|
+
out.truncated = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
out.rows.push(frame.row);
|
|
122
|
+
out.rowCount = out.rows.length;
|
|
123
|
+
}
|
|
124
|
+
else if ("end" in frame || "cancelled" in frame) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (out.truncated && out.cursor) {
|
|
129
|
+
await this.cancelQueryStream(out.cursor).catch(() => {
|
|
130
|
+
// best-effort: the cursor TTL reclaims it anyway.
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/** Cancel a streaming-query cursor server-side (`POST /query/stream/cancel`). */
|
|
136
|
+
async cancelQueryStream(cursor) {
|
|
137
|
+
await this.json("/query/stream/cancel", {
|
|
138
|
+
method: "POST",
|
|
139
|
+
body: JSON.stringify({ cursor }),
|
|
140
|
+
}).catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Run a read SELECT preferring the bounded-memory NDJSON stream, falling back to
|
|
145
|
+
* the buffered `POST /query` for everything the stream can't take — a non-SELECT
|
|
146
|
+
* statement, a network error, or an older server with no `/query/stream` route.
|
|
147
|
+
* Honest streaming negotiation (#22): identical to {@link RedClient.query}
|
|
148
|
+
* unless the server supports streaming AND the statement is a SELECT.
|
|
149
|
+
*/
|
|
150
|
+
export async function runQueryPreferStream(client, sql, opts = {}) {
|
|
151
|
+
if (!SELECT_RE.test(sql))
|
|
152
|
+
return client.query(sql);
|
|
153
|
+
try {
|
|
154
|
+
const s = await client.queryStreamCollect(sql, opts);
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
query: sql,
|
|
158
|
+
record_count: s.rowCount,
|
|
159
|
+
result: {
|
|
160
|
+
columns: s.columns,
|
|
161
|
+
records: s.rows.map((values) => ({ values })),
|
|
162
|
+
},
|
|
163
|
+
streamed: true,
|
|
164
|
+
truncated: s.truncated,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return client.query(sql);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=red-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"red-client.js","sourceRoot":"","sources":["../src/red-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type TargetMode = "remote" | "local";
|
|
2
|
+
/**
|
|
3
|
+
* Classify a connection target. An empty/absent target is `remote` (a cold
|
|
4
|
+
* open / Connect screen — there is nothing local to serve). `file://` and
|
|
5
|
+
* filesystem paths (including a bare `*.rdb`) are `local`; every other URL
|
|
6
|
+
* scheme — `http(s)://`, `red(s)://`, `grpc://` — and a bare `host:port` are
|
|
7
|
+
* `remote`. `red://` is remote on purpose: it is browser-reachable as an
|
|
8
|
+
* http(s) alias (ADR-0003), not a local file.
|
|
9
|
+
*/
|
|
10
|
+
export declare function classifyTarget(target: string | undefined | null): TargetMode;
|