diff --git a/CHANGELOG.md b/CHANGELOG.md index d530dfaa..3fd69060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Changes + +- `bin/qmd` gains an opt-in fast-path for `qmd search`. When + `QMD_DAEMON_URL` is set and points at a running + `qmd mcp --http --daemon`, `search` POSTs to the daemon's existing + `/search` REST endpoint instead of paying the per-call Node + + better-sqlite3 + sqlite-vec bootstrap. On large indexes this turns + a ~500–800 ms cold start into ~50–100 ms. Opt-in preserves the + default formatted-text output for interactive users; daemon mode + prints the JSON response for scripts/agents. Unrecognised flags + (`--json`, `--min-score`, …), `--index `, non-plain-positive + `-n` values, and any curl error fall through to the cold-start CLI + silently. `qmd vsearch` is intentionally NOT fast-pathed because + its vector-only semantics aren't expressible on the daemon's + current REST surface. + ### Fixes - GPU: respect explicit `QMD_LLAMA_GPU=metal|vulkan|cuda` backend overrides instead of always using auto GPU selection. #529 diff --git a/README.md b/README.md index 6f318446..6981b0eb 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,47 @@ Or configure MCP manually in `~/.claude/settings.json`: } ``` +#### Fast CLI via daemon (scripts / agents) + +For repeated scripted queries the per-call Node + SQLite + sqlite-vec +bootstrap (~500 ms on large indexes) is wasted overhead. Start a daemon +once and point `bin/qmd` at it: + +```sh +qmd mcp --http --daemon --port 8181 +export QMD_DAEMON_URL="http://127.0.0.1:8181" + +qmd search "database migrations" -c engineering # ~50 ms, returns JSON +``` + +`qmd search` routes through the daemon's existing `POST /search` endpoint +when `QMD_DAEMON_URL` is set. Responses are the structured JSON shape +(`{results:[{docid,file,title,score,snippet,context}]}`) — convenient for +agents and scripts parsing stdout. Interactive users who want the +formatted-text output should leave `QMD_DAEMON_URL` unset. + +The fast-path handles only `qmd search` (BM25 + hybrid via the daemon's +structured-query shape). `qmd vsearch` intentionally always runs the +cold-start CLI: its vector-only pipeline, minScore default, and rerank +behavior are not currently expressible on the daemon's `/search` +endpoint, and routing it would silently change result ordering. + +Fall-through cases (silently use cold-start CLI): + +- `QMD_DAEMON_URL` unset +- Daemon health check fails within 1 s +- Request returns a non-2xx +- Invocation uses `--index ` (daemons serve one index at a time) +- Unrecognised flag (e.g. `--min-score`, `--json`) — cold-start owns the full flag set +- Non-plain-positive-integer `-n` value + +Use `curl` to test payload directly: + +```sh +curl -s http://127.0.0.1:8181/search -H 'Content-Type: application/json' \ + -d '{"searches":[{"type":"lex","query":"auth"}],"collections":["engineering"],"limit":5}' +``` + #### HTTP Transport By default, QMD's MCP server uses stdio (launched as a subprocess by each client). For a shared, long-lived server that avoids repeated model loading, use the HTTP transport: diff --git a/bin/qmd b/bin/qmd index f658b3be..7b304afd 100755 --- a/bin/qmd +++ b/bin/qmd @@ -15,6 +15,176 @@ done # to avoid native module ABI mismatches (e.g., better-sqlite3 compiled for bun vs node) DIR="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" +# --------------------------------------------------------------------------- +# Daemon fast-path (opt-in, search only) +# +# When `QMD_DAEMON_URL` is set and points at a running `qmd mcp --http` daemon, +# route `qmd search` through the daemon's existing REST endpoint instead of +# bootstrapping a fresh Node + better-sqlite3 + sqlite-vec process for every +# invocation. On large indexes this can be the difference between ~80 ms and +# ~800 ms per query. +# +# Only `qmd search` is eligible. `qmd vsearch` is deliberately NOT handled +# here: vsearch's cold-start path sets a different `minScore` default (0.3 +# vs 0) and uses a vector-only pipeline without reranking, and the daemon's +# REST surface cannot currently express either choice. Routing vsearch +# through the fast-path would silently change result ordering and filtering. +# Cold-start remains the source of truth for vsearch semantics. +# +# Opt-in so the default interactive UX (formatted text output) is unchanged. +# Callers that set `QMD_DAEMON_URL` get the daemon's JSON response shape on +# stdout — useful for scripts and agents. Unset the variable (or pass +# `--index `) to fall back to the normal cold-start CLI. +# +# The fast-path logic lives in a shell function so its argument parsing +# operates on a local copy of `$@`. If the POST ultimately fails, the outer +# shell's original argv is untouched and the cold-start `exec` receives the +# exact command the user typed. +# +# Bypass rules: +# - Only `search` is eligible (vsearch deliberately excluded, see above). +# - Any `--index` / `--index=...` forces fall-through — the daemon serves +# whichever index it was started with, so secondary-index queries must go +# through the cold-start CLI. +# - Any curl/daemon error (health check fails, non-2xx response, empty query) +# falls back to cold-start silently. +# --------------------------------------------------------------------------- +_qmd_try_daemon() { + # $1 = subcommand ("search"); $2.. = subcommand args. + # Returns 0 on a successful daemon response (stdout already emitted), + # non-zero on any reason to fall through to cold-start. + [ -n "${QMD_DAEMON_URL:-}" ] || return 1 + command -v curl >/dev/null 2>&1 || return 1 + + case "$1" in + search) _qmd_qtype="lex" ;; + *) return 1 ;; + esac + shift + + # Detect --index before any destructive parsing. + for _arg in "$@"; do + case "$_arg" in + --index|--index=*) return 1 ;; + esac + done + + curl -sf --max-time 1 "${QMD_DAEMON_URL}/health" >/dev/null 2>&1 || return 1 + + # Match the interactive CLI's default result count so scripts that rely + # on `qmd search ` without an explicit -n don't see cardinality change + # based on whether QMD_DAEMON_URL is set. The `--files`/`--json` special + # case (default 20) can't apply here — those flags fall through to + # cold-start via the unknown-flag branch below. + _qmd_limit=5 + _qmd_collections_json="" # "" = unset; later serialised as null + _qmd_query="" + + # Upstream CLI flag shapes: -n for limit, -c for collection + # (collection supports `multiple: true`, so several -c flags must all be + # forwarded). Unknown dashed flags are dropped so we never leak them into + # the query text. + while [ $# -gt 0 ]; do + case "$1" in + -n|--n) + # Value-taking flag; bail out if the user left off the value + # (otherwise `shift 2` would abort the whole shell with + # "can't shift that many" instead of falling through). + [ $# -ge 2 ] || return 1 + _qmd_limit="$2" + shift 2 + ;; + --n=*) _qmd_limit="${1#*=}"; shift ;; + -c|--collection) + [ $# -ge 2 ] || return 1 + _qmd_c_escaped=$(printf '%s' "$2" | sed 's/\\/\\\\/g; s/"/\\"/g') + if [ -z "$_qmd_collections_json" ]; then + _qmd_collections_json="[\"${_qmd_c_escaped}\"]" + else + # Strip the closing `]`, append `,""]`. `${var%]}` is POSIX. + _qmd_collections_json="${_qmd_collections_json%]},\"${_qmd_c_escaped}\"]" + fi + shift 2 + ;; + --collection=*) + _qmd_c_val="${1#*=}" + _qmd_c_escaped=$(printf '%s' "$_qmd_c_val" | sed 's/\\/\\\\/g; s/"/\\"/g') + if [ -z "$_qmd_collections_json" ]; then + _qmd_collections_json="[\"${_qmd_c_escaped}\"]" + else + _qmd_collections_json="${_qmd_collections_json%]},\"${_qmd_c_escaped}\"]" + fi + shift + ;; + --) + shift + while [ $# -gt 0 ]; do + if [ -z "$_qmd_query" ]; then _qmd_query="$1" + else _qmd_query="$_qmd_query $1"; fi + shift + done + ;; + -*) + # Unknown flag. The upstream CLI accepts several options this + # parser can't safely decode (--min-score, --candidate-limit, + # --intent, --full, --json, --files, --all, ...). Some take a + # value, some are booleans. Rather than guess and risk eating + # the next token as part of the query, fall through to the + # cold-start CLI which knows the full flag set. The opt-in + # fast-path stays a strict subset of the interactive CLI. + return 1 ;; + *) + if [ -z "$_qmd_query" ]; then _qmd_query="$1" + else _qmd_query="$_qmd_query $1"; fi + shift ;; + esac + done + + [ -n "$_qmd_query" ] || return 1 + + # -n policy in the fast-path: accept ONLY a plain positive integer with + # no sign, no leading zeros, no trailing non-digits, no exponent, etc. + # Any other shape (`-1`, `+7`, `0`, `1e2`, `007`, `5abc`, empty) falls + # through to the cold-start CLI, which owns the edge-case semantics. + # This keeps the fast-path a strict subset of interactive behaviour + # without trying to emulate `parseInt(...) || default` in POSIX sh. + # + # An unset -n is the common case — we already defaulted `_qmd_limit=5`. + # Skip the strict check on that value. + if [ "$_qmd_limit" != "5" ]; then + case "$_qmd_limit" in + ''|0|0[0-9]*|*[!0-9]*) return 1 ;; + esac + fi + + _qmd_q_escaped=$(printf '%s' "$_qmd_query" | sed 's/\\/\\\\/g; s/"/\\"/g') + if [ -z "$_qmd_collections_json" ]; then + _qmd_collections_json="null" + fi + + _qmd_payload="{\"searches\":[{\"type\":\"${_qmd_qtype}\",\"query\":\"${_qmd_q_escaped}\"}],\"collections\":${_qmd_collections_json},\"limit\":${_qmd_limit}}" + + # Buffer the daemon response so a mid-stream failure (connection reset, + # --max-time elapsing after bytes have been written) can't leak a partial + # payload before we fall through to the cold-start CLI. Script consumers + # should see either the daemon's complete JSON response OR the cold-start + # output — never both concatenated. + _qmd_response=$(curl -sf --max-time 30 -X POST \ + -H "Content-Type: application/json" \ + -d "$_qmd_payload" \ + "${QMD_DAEMON_URL}/search") || return 1 + printf '%s\n' "$_qmd_response" + return 0 +} + +case "${1:-}" in + search) + if _qmd_try_daemon "$@"; then + exit 0 + fi + ;; +esac + # Detect the package manager that installed dependencies by checking lockfiles. # $BUN_INSTALL is intentionally NOT checked — it only indicates that bun exists # on the system, not that it was used to install this package (see #361). diff --git a/src/db.ts b/src/db.ts index 5fe7ab47..9b6e426a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -69,6 +69,7 @@ export interface Database { exec(sql: string): void; prepare(sql: string): Statement; loadExtension(path: string): void; + transaction any>(fn: T): T; close(): void; } diff --git a/test/bin-qmd-daemon-fastpath.test.ts b/test/bin-qmd-daemon-fastpath.test.ts new file mode 100644 index 00000000..a94e8150 --- /dev/null +++ b/test/bin-qmd-daemon-fastpath.test.ts @@ -0,0 +1,284 @@ +/** + * Integration tests for bin/qmd's daemon fast-path. + * + * These do NOT bootstrap the real MCP server. Instead they stand up a tiny + * HTTP mock on a random port, export QMD_DAEMON_URL, and spawn bin/qmd to + * verify the shell wrapper: + * + * 1. Sends the expected POST body shape (type, query, collections, limit). + * 2. Accepts -n for limit (upstream CLI flag, not -l). + * 3. Accumulates multiple -c / --collection flags. + * 4. Falls through to cold-start with the ORIGINAL argv when /health or + * POST fails. + */ +import { describe, test, expect, afterAll } from "vitest"; +import { spawn } from "node:child_process"; +import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; +import { resolve } from "node:path"; + +interface Capture { + method: string; + path: string; + body: string; +} + +async function startMockServer( + healthStatus: number, + searchStatus: number, + searchBody: string +): Promise<{ server: Server; url: string; captures: Capture[] }> { + const captures: Capture[] = []; + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const body = Buffer.concat(chunks).toString("utf8"); + captures.push({ method: req.method || "GET", path: req.url || "/", body }); + + if (req.url === "/health") { + res.writeHead(healthStatus, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + if (req.url === "/search") { + res.writeHead(searchStatus, { "Content-Type": "application/json" }); + res.end(searchBody); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as { port: number }).port; + return { server, url: `http://127.0.0.1:${port}`, captures }; +} + +const BIN_QMD = resolve(__dirname, "..", "bin", "qmd"); + +/** + * Async spawn so the event loop keeps running while bin/qmd's curl talks to + * the mock HTTP server living in this same Node process. spawnSync would + * block the loop and the mock could never respond. + */ +function runBin(args: string[], env: NodeJS.ProcessEnv): Promise<{ status: number; stdout: string; stderr: string }> { + return new Promise((resolvePromise) => { + const child = spawn("sh", [BIN_QMD, ...args], { + env: { ...process.env, ...env }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += String(d))); + child.stderr.on("data", (d) => (stderr += String(d))); + const killTimer = setTimeout(() => child.kill(), 10_000); + child.on("close", (code) => { + clearTimeout(killTimer); + resolvePromise({ status: code ?? -1, stdout, stderr }); + }); + }); +} + +describe("bin/qmd daemon fast-path", () => { + let mock: { server: Server; url: string; captures: Capture[] }; + + afterAll(async () => { + if (mock?.server) await new Promise((r) => mock.server.close(() => r())); + }); + + test("POSTs correct shape for `qmd search -c -n `", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [{ docid: "aa", file: "x.md", title: "X", score: 1 }] })); + const run = await runBin(["search", "hello", "-c", "alpha", "-n", "3"], { QMD_DAEMON_URL: mock.url }); + if (run.status !== 0) { + console.error("runBin stderr:", run.stderr); + console.error("runBin stdout:", run.stdout); + console.error("mock captures:", mock.captures); + } + expect(run.status).toBe(0); + const searchReq = mock.captures.find((c) => c.path === "/search"); + expect(searchReq).toBeDefined(); + const payload = JSON.parse(searchReq!.body); + expect(payload.searches).toEqual([{ type: "lex", query: "hello" }]); + expect(payload.collections).toEqual(["alpha"]); + expect(payload.limit).toBe(3); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("vsearch is intentionally NOT handled by the fast-path", async () => { + // vsearch's cold-start semantics (minScore: 0.3, vector-only, no rerank) + // aren't expressible on the daemon's /search endpoint. Routing it would + // silently change result ordering and filtering. Strict-subset + // principle: vsearch always goes through cold-start, even when + // QMD_DAEMON_URL is set. + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["vsearch", "semantic query"], { QMD_DAEMON_URL: mock.url }); + // No /health or /search requests should reach the daemon at all. + expect(mock.captures.length).toBe(0); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("multiple -c flags are accumulated in collections", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["search", "foo", "-c", "alpha", "-c", "beta", "-c", "gamma"], { QMD_DAEMON_URL: mock.url }); + const searchReq = mock.captures.find((c) => c.path === "/search"); + expect(searchReq).toBeDefined(); + const payload = JSON.parse(searchReq!.body); + expect(payload.collections).toEqual(["alpha", "beta", "gamma"]); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("--collection= form is accepted alongside -c", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["search", "q", "-c", "one", "--collection=two"], { QMD_DAEMON_URL: mock.url }); + const searchReq = mock.captures.find((c) => c.path === "/search"); + expect(searchReq).toBeDefined(); + const payload = JSON.parse(searchReq!.body); + expect(payload.collections).toEqual(["one", "two"]); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("unset -c yields collections: null", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["search", "untouched"], { QMD_DAEMON_URL: mock.url }); + const searchReq = mock.captures.find((c) => c.path === "/search"); + expect(searchReq).toBeDefined(); + const payload = JSON.parse(searchReq!.body); + expect(payload.collections).toBeNull(); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("unset -n uses 5 to match the interactive CLI default", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["search", "foo"], { QMD_DAEMON_URL: mock.url }); + const searchReq = mock.captures.find((c) => c.path === "/search"); + expect(searchReq).toBeDefined(); + const payload = JSON.parse(searchReq!.body); + expect(payload.limit).toBe(5); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("strict -n validation: non-plain-positive-integer values fall through", async () => { + // Rather than emulate JS parseInt semantics in POSIX shell (and chase + // edge cases round after round), the fast-path only accepts -n values + // that are unambiguous plain positive integers. Anything else — signed, + // zero, zero-padded, alphanumeric, exponent notation, empty — falls + // through to the cold-start CLI, where parseInt || default applies. + const cases: string[] = ["0", "00", "007", "-1", "+7", "1e2", "5abc", "abc"]; + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + for (const value of cases) { + mock.captures.length = 0; + await runBin(["search", "foo", "-n", value], { QMD_DAEMON_URL: mock.url }); + const searchReq = mock.captures.find((c) => c.path === "/search"); + expect(searchReq, `-n ${value} should fall through`).toBeUndefined(); + } + await new Promise((r) => mock.server.close(() => r())); + }); + + test("plain positive integers in -n are forwarded as-is", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + for (const [input, expected] of [["1", 1], ["3", 3], ["20", 20], ["100", 100]] as [string, number][]) { + mock.captures.length = 0; + await runBin(["search", "foo", "-n", input], { QMD_DAEMON_URL: mock.url }); + const searchReq = mock.captures.find((c) => c.path === "/search"); + expect(searchReq, `-n ${input} should hit the daemon`).toBeDefined(); + const payload = JSON.parse(searchReq!.body); + expect(payload.limit).toBe(expected); + } + await new Promise((r) => mock.server.close(() => r())); + }); + + test("--index bypasses daemon entirely (no /health call)", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["--index", "library", "search", "foo"], { QMD_DAEMON_URL: mock.url }); + expect(mock.captures.length).toBe(0); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("non-search subcommands don't touch the daemon", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["status"], { QMD_DAEMON_URL: mock.url }); + expect(mock.captures.find((c) => c.path === "/search")).toBeUndefined(); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("unknown flag falls through rather than eating its value into the query", async () => { + // --min-score is a real upstream option that takes a value. If the + // fast-path blindly skipped just the flag token, `0.8` would get + // appended to `_qmd_query`. Correct behavior is to bail so the + // cold-start CLI (which knows the full flag set) handles it. + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["search", "foo", "--min-score", "0.8"], { QMD_DAEMON_URL: mock.url }); + expect(mock.captures.find((c) => c.path === "/search")).toBeUndefined(); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("boolean flags also fall through (e.g. --json)", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + await runBin(["search", "foo", "--json"], { QMD_DAEMON_URL: mock.url }); + expect(mock.captures.find((c) => c.path === "/search")).toBeUndefined(); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("dangling value flag (-n with no argument) falls through without aborting shell", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + const run = await runBin(["search", "foo", "-n"], { QMD_DAEMON_URL: mock.url }); + // The fast-path must return non-zero and let cold-start handle it. + // Most importantly it must NOT abort /bin/sh with + // "shift: can't shift that many" (exit 2 or higher from /bin/sh). + expect(run.stderr).not.toMatch(/can't shift|shift.*many/); + expect(mock.captures.find((c) => c.path === "/search")).toBeUndefined(); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("dangling -c with no argument also falls through cleanly", async () => { + mock = await startMockServer(200, 200, JSON.stringify({ results: [] })); + const run = await runBin(["search", "foo", "-c"], { QMD_DAEMON_URL: mock.url }); + expect(run.stderr).not.toMatch(/can't shift|shift.*many/); + expect(mock.captures.find((c) => c.path === "/search")).toBeUndefined(); + await new Promise((r) => mock.server.close(() => r())); + }); + + test("daemon that writes partial bytes then drops the connection leaks no output", async () => { + // A slow-failing /search must NOT emit partial JSON on stdout before the + // fast-path falls through, or scripts will see daemon-JSON + cold-start + // text concatenated on the same invocation. + const captures: Capture[] = []; + const partialServer = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + captures.push({ method: req.method || "GET", path: req.url || "/", body: Buffer.concat(chunks).toString("utf8") }); + if (req.url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end('{"status":"ok"}'); + return; + } + if (req.url === "/search") { + // Start writing then abruptly destroy the socket so curl sees a + // failed response after partial bytes. + res.writeHead(200, { "Content-Type": "application/json" }); + res.write('{"partial":'); + res.socket?.destroy(); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((r) => partialServer.listen(0, "127.0.0.1", () => r())); + const port = (partialServer.address() as { port: number }).port; + const url = `http://127.0.0.1:${port}`; + try { + const run = await runBin(["search", "foo"], { QMD_DAEMON_URL: url }); + // The fast-path must not emit the partial `{"partial":` on stdout. + // The cold-start CLI may or may not also run in the test env; what + // matters is that daemon bytes do not appear when the daemon failed. + expect(run.stdout).not.toContain("partial"); + } finally { + await new Promise((r) => partialServer.close(() => r())); + } + }); + + test("health-check failure silently falls through (no /search attempted)", async () => { + mock = await startMockServer(500, 200, "{}"); + await runBin(["search", "foo"], { QMD_DAEMON_URL: mock.url }); + const searchCall = mock.captures.find((c) => c.path === "/search"); + expect(searchCall).toBeUndefined(); + await new Promise((r) => mock.server.close(() => r())); + }); +}); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 3ea87bd4..06f83e9a 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -974,6 +974,72 @@ describe.skipIf(!!process.env.CI)("MCP HTTP Transport", () => { expect(res.status).toBe(404); }); + // --------------------------------------------------------------------------- + // POST /search REST contract (consumed by bin/qmd daemon fast-path) + // --------------------------------------------------------------------------- + + test("POST /search accepts the fast-path shape and returns structured results", async () => { + const res = await fetch(`${baseUrl}/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + searches: [{ type: "lex", query: "readme" }], + collections: ["docs"], + limit: 5, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const body = await res.json(); + expect(Array.isArray(body.results)).toBe(true); + // Each result should expose the fields bin/qmd's fast-path output relies on. + for (const r of body.results) { + expect(typeof r.docid).toBe("string"); + expect(typeof r.file).toBe("string"); + expect(typeof r.title).toBe("string"); + expect(typeof r.score).toBe("number"); + } + }); + + test("POST /search accepts null collections (fast-path's unset -c default)", async () => { + const res = await fetch(`${baseUrl}/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + searches: [{ type: "lex", query: "readme" }], + collections: null, + limit: 3, + }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.results)).toBe(true); + }); + + test("POST /search with missing `searches` returns 400", async () => { + const res = await fetch(`${baseUrl}/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ limit: 1 }), + }); + expect(res.status).toBe(400); + }); + + test("POST /search accepts type='vec' (vsearch fast-path)", async () => { + const res = await fetch(`${baseUrl}/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + searches: [{ type: "vec", query: "readme" }], + collections: ["docs"], + limit: 3, + }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.results)).toBe(true); + }); + // --------------------------------------------------------------------------- // MCP protocol over HTTP // ---------------------------------------------------------------------------