@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.3
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/core/src/cursor.ts +27 -9
- package/core/src/links.ts +63 -3
- package/core/src/mcp.ts +155 -23
- package/core/src/notes.ts +226 -37
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +219 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +11 -3
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +32 -5
- package/package.json +1 -1
- package/src/contract-honest-queries.test.ts +350 -38
- package/src/contract-search.test.ts +263 -31
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +32 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +327 -23
- package/src/ws-subscribe.test.ts +14 -0
- package/web/ui/dist/assets/{index-TJ-XN4OZ.js → index-B8pGeQam.js} +1 -1
- package/web/ui/dist/index.html +1 -1
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contract suite — search (Wave 1
|
|
3
|
-
* umbrella #556). Encodes the 2026-07-09
|
|
4
|
-
*
|
|
5
|
-
* that is correct today; `test.todo`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Contract suite — search (Wave 1's findings, Wave 3's fix — Reliability &
|
|
3
|
+
* Usability Program, umbrella #556, WS2A #551). Encodes the 2026-07-09
|
|
4
|
+
* nine-persona deep test's search findings as executable tests: PASSING
|
|
5
|
+
* tests lock in behavior that is correct today; the #551 `test.todo`
|
|
6
|
+
* entries this file used to carry have all been flipped to real assertions
|
|
7
|
+
* against the literal-by-default fix (escaping, `search_mode: "advanced"`,
|
|
8
|
+
* honored `sort`, structured `invalid_search_syntax`) — see #551 for the
|
|
9
|
+
* full write-up. Covers `src/routes.ts` (the REST surface) plus a handful
|
|
10
|
+
* of MCP-parity checks (`core/src/mcp.ts`) where the two surfaces share one
|
|
11
|
+
* implementation (`core/src/notes.ts` `searchNotes` + `core/src/search-
|
|
12
|
+
* query.ts`) and a REST-only test wouldn't prove the MCP side actually got
|
|
13
|
+
* the fix — mirrors the pattern in `contract-honest-queries.test.ts`.
|
|
8
14
|
*
|
|
9
15
|
* Ground truth for every assertion here was re-verified live against this
|
|
10
16
|
* repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
|
|
@@ -16,6 +22,7 @@ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
|
16
22
|
import { Database } from "bun:sqlite";
|
|
17
23
|
import { BunStore } from "./vault-store.ts";
|
|
18
24
|
import { handleNotes } from "./routes.ts";
|
|
25
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
19
26
|
|
|
20
27
|
let db: Database;
|
|
21
28
|
let store: BunStore;
|
|
@@ -26,6 +33,13 @@ function search(qs: string): Promise<Response> {
|
|
|
26
33
|
return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
|
|
37
|
+
function decodeWarningsHeader(res: Response): any[] | null {
|
|
38
|
+
const raw = res.headers.get("X-Parachute-Warnings");
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
return JSON.parse(decodeURIComponent(raw));
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
|
|
30
44
|
const NOTES = {
|
|
31
45
|
hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
|
|
@@ -76,22 +90,36 @@ describe("contract: search — passing (lock in current behavior)", () => {
|
|
|
76
90
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
77
91
|
});
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
// The next three tests manually quote the search string, expecting raw
|
|
94
|
+
// FTS5 phrase syntax to be honored. That's advanced-mode behavior as of
|
|
95
|
+
// #551 (literal-by-default escapes a caller's own `"` characters as
|
|
96
|
+
// ordinary content instead of honoring them as phrase syntax) — updated
|
|
97
|
+
// to pass `search_mode: "advanced"` explicitly. (Note for reviewers:
|
|
98
|
+
// empirically the bare literal-mode escaping of these SAME manually-
|
|
99
|
+
// quoted strings still resolves to the identical result set for this
|
|
100
|
+
// corpus — FTS5's tokenizer strips the embedded `"` characters the same
|
|
101
|
+
// way it strips other punctuation — but the semantic guarantee these
|
|
102
|
+
// tests were written to pin (phrase ADJACENCY honored as syntax) only
|
|
103
|
+
// holds under advanced mode; literal mode no longer enforces adjacency
|
|
104
|
+
// at all, it just happens to agree here because no other note contains
|
|
105
|
+
// a subset of the same words. See #551 test coverage for the literal-
|
|
106
|
+
// mode equivalents of these same strings, unquoted, further down.)
|
|
107
|
+
it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay" (search_mode: "advanced")', async () => {
|
|
108
|
+
const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&search_mode=advanced&include_content=true`);
|
|
81
109
|
expect(res.status).toBe(200);
|
|
82
110
|
const body = await bodyOf(res);
|
|
83
111
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
84
112
|
});
|
|
85
113
|
|
|
86
|
-
it('quoted decimal literal finds it: "18.6"', async () => {
|
|
87
|
-
const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
|
|
114
|
+
it('quoted decimal literal finds it: "18.6" (search_mode: "advanced")', async () => {
|
|
115
|
+
const res = await search(`search=${encodeURIComponent('"18.6"')}&search_mode=advanced&include_content=true`);
|
|
88
116
|
expect(res.status).toBe(200);
|
|
89
117
|
const body = await bodyOf(res);
|
|
90
118
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
91
119
|
});
|
|
92
120
|
|
|
93
|
-
it('quoted contraction finds it: "didn\'t"', async () => {
|
|
94
|
-
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
|
|
121
|
+
it('quoted contraction finds it: "didn\'t" (search_mode: "advanced")', async () => {
|
|
122
|
+
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&search_mode=advanced&include_content=true`);
|
|
95
123
|
expect(res.status).toBe(200);
|
|
96
124
|
const body = await bodyOf(res);
|
|
97
125
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
@@ -105,23 +133,227 @@ describe("contract: search — passing (lock in current behavior)", () => {
|
|
|
105
133
|
});
|
|
106
134
|
});
|
|
107
135
|
|
|
108
|
-
describe(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
describe('contract: search — literal-by-default (#551, flipped from todo)', () => {
|
|
137
|
+
it(`unquoted search "didn't" finds the contraction content (literal-by-default — the bare apostrophe used to split into two AND'd tokens and return [])`, async () => {
|
|
138
|
+
const res = await search(`search=${encodeURIComponent("didn't")}&include_content=true`);
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
const body = await bodyOf(res);
|
|
141
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it(`unquoted search "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — the bare hyphenated word used to tokenize into a NOT-operator and return [])`, async () => {
|
|
145
|
+
const res = await search(`search=${encodeURIComponent("eleven-day capping delay")}&include_content=true`);
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
const body = await bodyOf(res);
|
|
148
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it(`unquoted search "18.6" finds the decimal note (literal-by-default — the bare decimal used to break the FTS5 parse and return [])`, async () => {
|
|
152
|
+
const res = await search(`search=${encodeURIComponent("18.6")}&include_content=true`);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
const body = await bodyOf(res);
|
|
155
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('search_mode:"advanced" preserves raw FTS5 query syntax (boolean AND/NOT, prefix *)', async () => {
|
|
159
|
+
const and = await bodyOf(
|
|
160
|
+
await search(`search=${encodeURIComponent("widgets AND gadgets")}&search_mode=advanced&include_content=true`),
|
|
161
|
+
);
|
|
162
|
+
expect(and.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
163
|
+
|
|
164
|
+
const not = await bodyOf(
|
|
165
|
+
await search(`search=${encodeURIComponent("widgets NOT gadgets")}&search_mode=advanced&include_content=true`),
|
|
166
|
+
);
|
|
167
|
+
expect(not.map((n: any) => n.content)).toEqual([NOTES.widgetsOnly]);
|
|
168
|
+
|
|
169
|
+
const prefix = await bodyOf(
|
|
170
|
+
await search(`search=${encodeURIComponent("wid*")}&search_mode=advanced&include_content=true`),
|
|
171
|
+
);
|
|
172
|
+
const prefixContents = new Set(prefix.map((n: any) => n.content));
|
|
173
|
+
expect(prefixContents.has(NOTES.widgetsOnly)).toBe(true);
|
|
174
|
+
expect(prefixContents.has(NOTES.bothWords)).toBe(true);
|
|
175
|
+
expect(prefixContents.has(NOTES.gadgetsOnly)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('sort:"asc"/"desc" changes result order under search (previously silently ignored — FTS5 rank order won regardless of the sort param)', async () => {
|
|
179
|
+
await store.createNote("sortprobe alpha content", { created_at: "2020-01-01T00:00:00.000Z" });
|
|
180
|
+
await store.createNote("sortprobe beta content", { created_at: "2022-06-15T00:00:00.000Z" });
|
|
181
|
+
await store.createNote("sortprobe gamma content", { created_at: "2024-12-31T00:00:00.000Z" });
|
|
182
|
+
|
|
183
|
+
const asc = await bodyOf(await search("search=sortprobe&sort=asc&include_content=true"));
|
|
184
|
+
expect(asc.map((n: any) => n.content)).toEqual([
|
|
185
|
+
"sortprobe alpha content",
|
|
186
|
+
"sortprobe beta content",
|
|
187
|
+
"sortprobe gamma content",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const desc = await bodyOf(await search("search=sortprobe&sort=desc&include_content=true"));
|
|
191
|
+
expect(desc.map((n: any) => n.content)).toEqual([
|
|
192
|
+
"sortprobe gamma content",
|
|
193
|
+
"sortprobe beta content",
|
|
194
|
+
"sortprobe alpha content",
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("malformed FTS syntax under search_mode:\"advanced\" yields a structured 400 invalid_search_syntax error, not a silently swallowed []", async () => {
|
|
199
|
+
const res = await search(`search=${encodeURIComponent('"unbalanced')}&search_mode=advanced`);
|
|
200
|
+
expect(res.status).toBe(400);
|
|
201
|
+
const body = await bodyOf(res);
|
|
202
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
203
|
+
expect(body.error_type).toBe("invalid_search_syntax");
|
|
204
|
+
expect(body.field).toBe("search");
|
|
205
|
+
expect(body.got).toBe('"unbalanced');
|
|
206
|
+
expect(typeof body.hint).toBe("string");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("the SAME malformed string in literal mode (default) returns an honest result, not an error — escaping makes it syntactically valid FTS5", async () => {
|
|
210
|
+
const res = await search(`search=${encodeURIComponent('"unbalanced')}`);
|
|
211
|
+
expect(res.status).toBe(200);
|
|
212
|
+
const body = await bodyOf(res);
|
|
213
|
+
expect(Array.isArray(body)).toBe(true);
|
|
214
|
+
expect(body).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Review fold: a NUL byte inside a literal-mode token used to crash FTS5's
|
|
218
|
+
// C-string parser (`SQLiteError: unterminated string`) → raw rethrow →
|
|
219
|
+
// unstructured 500. Sanitization (control chars → separators) makes it a
|
|
220
|
+
// normal search. `%00` is the URL-encoding of a NUL byte.
|
|
221
|
+
it("search=hello%00world (NUL byte in a token) does NOT 500 — literal mode cannot throw", async () => {
|
|
222
|
+
await store.createNote("hello world reachable across the NUL split", { tags: ["nul"] });
|
|
223
|
+
const res = await search("search=hello%00world&include_content=true");
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
const body = await bodyOf(res);
|
|
226
|
+
expect(Array.isArray(body)).toBe(true);
|
|
227
|
+
// The NUL split the token into "hello" AND "world" — both searchable —
|
|
228
|
+
// so the planted note (containing both words) is found.
|
|
229
|
+
expect(body.some((n: any) => n.content.includes("hello world reachable"))).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("a NUL-only search short-circuits to [] with an empty_search warning, no 500", async () => {
|
|
233
|
+
const res = await search("search=%00");
|
|
234
|
+
expect(res.status).toBe(200);
|
|
235
|
+
const body = await bodyOf(res);
|
|
236
|
+
expect(body).toEqual([]);
|
|
237
|
+
const warnings = decodeWarningsHeader(res);
|
|
238
|
+
expect(warnings).not.toBeNull();
|
|
239
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("sort under search is STABLE at identical created_at (deterministic n.id tiebreaker, both directions)", async () => {
|
|
243
|
+
// Two notes at the SAME millisecond — without the id tiebreaker their
|
|
244
|
+
// relative order would be arbitrary/unstable (review nit).
|
|
245
|
+
const ts = "2023-03-03T03:03:03.030Z";
|
|
246
|
+
await store.createNote("tiebreak content one", { id: "tie-aaa", created_at: ts });
|
|
247
|
+
await store.createNote("tiebreak content two", { id: "tie-bbb", created_at: ts });
|
|
248
|
+
|
|
249
|
+
const asc = await bodyOf(await search("search=tiebreak&sort=asc&include_content=true"));
|
|
250
|
+
const ascIds = asc.map((n: any) => n.id);
|
|
251
|
+
// ASC id tiebreaker → tie-aaa before tie-bbb.
|
|
252
|
+
expect(ascIds).toEqual(["tie-aaa", "tie-bbb"]);
|
|
253
|
+
|
|
254
|
+
const desc = await bodyOf(await search("search=tiebreak&sort=desc&include_content=true"));
|
|
255
|
+
const descIds = desc.map((n: any) => n.id);
|
|
256
|
+
// DESC id tiebreaker → tie-bbb before tie-aaa (the exact reverse).
|
|
257
|
+
expect(descIds).toEqual(["tie-bbb", "tie-aaa"]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("contract: search — escaping edge cases, tag-scope, warnings (#551)", () => {
|
|
262
|
+
it("a double space between tokens still finds the note containing both (whitespace runs collapse)", async () => {
|
|
263
|
+
const res = await search(`search=${encodeURIComponent("widgets gadgets")}&include_content=true`);
|
|
264
|
+
expect(res.status).toBe(200);
|
|
265
|
+
const body = await bodyOf(res);
|
|
266
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("whitespace-only search short-circuits to [] with an empty_search warning, no FTS5 call/error", async () => {
|
|
270
|
+
const res = await search(`search=${encodeURIComponent(" ")}`);
|
|
271
|
+
expect(res.status).toBe(200);
|
|
272
|
+
const body = await bodyOf(res);
|
|
273
|
+
expect(body).toEqual([]);
|
|
274
|
+
const warnings = decodeWarningsHeader(res);
|
|
275
|
+
expect(warnings).not.toBeNull();
|
|
276
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a quotes-only search (e.g. a lone `"`) short-circuits to [] with an empty_search warning', async () => {
|
|
280
|
+
const res = await search(`search=${encodeURIComponent('"')}`);
|
|
281
|
+
expect(res.status).toBe(200);
|
|
282
|
+
const body = await bodyOf(res);
|
|
283
|
+
expect(body).toEqual([]);
|
|
284
|
+
const warnings = decodeWarningsHeader(res);
|
|
285
|
+
expect(warnings).not.toBeNull();
|
|
286
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("tag-filtered search also gets literal escaping (the tag-scoped FTS prepared statement)", async () => {
|
|
290
|
+
await store.createNote(NOTES.hyphenPhrase, { tags: ["probe"] });
|
|
291
|
+
const res = await search(
|
|
292
|
+
`search=${encodeURIComponent("eleven-day capping delay")}&tag=probe&include_content=true`,
|
|
293
|
+
);
|
|
294
|
+
expect(res.status).toBe(200);
|
|
295
|
+
const body = await bodyOf(res);
|
|
296
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
297
|
+
expect(body[0].tags).toContain("probe");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("search_mode without search is a no-op that surfaces an ignored_param warning (structured-query path)", async () => {
|
|
301
|
+
const res = await search("search_mode=advanced");
|
|
302
|
+
expect(res.status).toBe(200);
|
|
303
|
+
const warnings = decodeWarningsHeader(res);
|
|
304
|
+
expect(warnings).not.toBeNull();
|
|
305
|
+
expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("an unrecognized search_mode value is a structured 400 invalid_query", async () => {
|
|
309
|
+
const res = await search("search=widgets&search_mode=bogus");
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
const body = await bodyOf(res);
|
|
312
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
313
|
+
expect(body.error_type).toBe("invalid_query");
|
|
314
|
+
expect(body.field).toBe("search_mode");
|
|
315
|
+
expect(body.got).toBe("bogus");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("contract: search — MCP parity (#551)", () => {
|
|
320
|
+
it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
|
|
321
|
+
const tools = generateMcpTools(store);
|
|
322
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
323
|
+
const result = (await query.execute({ search: "didn't", include_content: true })) as any[];
|
|
324
|
+
expect(result.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("query-notes: search_mode validation rejects an unknown value", async () => {
|
|
328
|
+
const tools = generateMcpTools(store);
|
|
329
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
330
|
+
await expect(query.execute({ search: "widgets", search_mode: "bogus" })).rejects.toThrow(/search_mode/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("query-notes: search_mode without search surfaces an ignored_param warning", async () => {
|
|
334
|
+
const tools = generateMcpTools(store);
|
|
335
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
336
|
+
const result = (await query.execute({ search_mode: "advanced" })) as any;
|
|
337
|
+
expect(result.warnings).toBeDefined();
|
|
338
|
+
expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("query-notes: search_mode:\"advanced\" + malformed syntax throws a structured invalid_search_syntax error", async () => {
|
|
342
|
+
const tools = generateMcpTools(store);
|
|
343
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
344
|
+
await expect(
|
|
345
|
+
query.execute({ search: '"unbalanced', search_mode: "advanced" }),
|
|
346
|
+
).rejects.toMatchObject({ error_type: "invalid_search_syntax" });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("query-notes: sort:\"asc\"/\"desc\" changes order under search (parity with REST)", async () => {
|
|
350
|
+
await store.createNote("mcpsortprobe alpha", { created_at: "2020-01-01T00:00:00.000Z" });
|
|
351
|
+
await store.createNote("mcpsortprobe beta", { created_at: "2024-01-01T00:00:00.000Z" });
|
|
352
|
+
const tools = generateMcpTools(store);
|
|
353
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
354
|
+
const asc = (await query.execute({ search: "mcpsortprobe", sort: "asc", include_content: true })) as any[];
|
|
355
|
+
expect(asc.map((n: any) => n.content)).toEqual(["mcpsortprobe alpha", "mcpsortprobe beta"]);
|
|
356
|
+
const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
|
|
357
|
+
expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
|
|
358
|
+
});
|
|
127
359
|
});
|
package/src/live-match.test.ts
CHANGED
|
@@ -196,3 +196,20 @@ describe("live-match — predicate parity with the query engine", () => {
|
|
|
196
196
|
await assertParity({ tags: ["t"], metadata: { kind: "note" } });
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("live-match — unsupportedSubscriptionReason cursor guard (vault#550)", () => {
|
|
201
|
+
it("rejects a bootstrap empty-string cursor (presence, not truthiness)", async () => {
|
|
202
|
+
const { unsupportedSubscriptionReason } = await import("./live-match.ts");
|
|
203
|
+
// `cursor: ""` is cursor INTENT (the vault#550 bootstrap call) — a live
|
|
204
|
+
// subscription can't paginate, so it must be rejected the same as a
|
|
205
|
+
// real cursor, not silently treated as "no cursor requested."
|
|
206
|
+
expect(unsupportedSubscriptionReason({ cursor: "" } as QueryOpts)).toContain("cursor");
|
|
207
|
+
expect(unsupportedSubscriptionReason({ cursor: "eyJxaC" } as QueryOpts)).toContain("cursor");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("an omitted/undefined cursor does not reject", async () => {
|
|
211
|
+
const { unsupportedSubscriptionReason } = await import("./live-match.ts");
|
|
212
|
+
expect(unsupportedSubscriptionReason({ cursor: undefined } as QueryOpts)).toBeNull();
|
|
213
|
+
expect(unsupportedSubscriptionReason({} as QueryOpts)).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/live-match.ts
CHANGED
|
@@ -76,7 +76,10 @@ export function unsupportedSubscriptionReason(opts: QueryOpts): string | null {
|
|
|
76
76
|
// subscribe route detects them from raw query params. These guards catch the
|
|
77
77
|
// remaining shapes that the matcher can't faithfully evaluate against a
|
|
78
78
|
// single in-hand note (so snapshot and live would disagree).
|
|
79
|
-
|
|
79
|
+
// Presence, not truthiness (vault#550) — a bootstrap `cursor: ""` is
|
|
80
|
+
// still cursor intent and must still be rejected here, not silently
|
|
81
|
+
// treated as "no cursor requested."
|
|
82
|
+
if (opts.cursor !== undefined) {
|
|
80
83
|
return "cursor pagination is not supported for live subscriptions";
|
|
81
84
|
}
|
|
82
85
|
if (opts.hasLinks !== undefined) {
|
package/src/mcp-http.ts
CHANGED
|
@@ -156,7 +156,39 @@ async function handleMcp(
|
|
|
156
156
|
to?: unknown;
|
|
157
157
|
current?: unknown;
|
|
158
158
|
violations?: unknown;
|
|
159
|
+
error_type?: string;
|
|
160
|
+
got?: unknown;
|
|
161
|
+
hint?: string;
|
|
159
162
|
};
|
|
163
|
+
// Honest-queries validation errors (vault#550) — `limit`/`offset`/date
|
|
164
|
+
// values that are structurally invalid rather than merely "no
|
|
165
|
+
// results." Gated on `error_type === "invalid_query"` specifically
|
|
166
|
+
// (not `code === "INVALID_QUERY"` broadly) so long-standing QueryError
|
|
167
|
+
// throws WITHOUT this field (FIELD_NOT_INDEXED, UNKNOWN_OPERATOR, the
|
|
168
|
+
// various cursor/near/search incompatibility errors, ...) keep their
|
|
169
|
+
// existing unstructured `isError: true` fallback — only the NEW #550
|
|
170
|
+
// call sites that explicitly set `error_type` opt into this shape.
|
|
171
|
+
if (e?.error_type === "invalid_query") {
|
|
172
|
+
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
173
|
+
error_type: "invalid_query",
|
|
174
|
+
field: e.field,
|
|
175
|
+
got: e.got,
|
|
176
|
+
hint: e.hint,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Advanced-mode full-text search syntax error (vault#551) — a
|
|
180
|
+
// raw-passthrough FTS5 query that FTS5 itself rejected. Same shape as
|
|
181
|
+
// `invalid_query` (field/got/hint) but a DISTINCT `error_type` so a
|
|
182
|
+
// caller can tell "your search_mode:\"advanced\" syntax is malformed"
|
|
183
|
+
// apart from "your query PARAMS are malformed."
|
|
184
|
+
if (e?.error_type === "invalid_search_syntax") {
|
|
185
|
+
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
186
|
+
error_type: "invalid_search_syntax",
|
|
187
|
+
field: e.field,
|
|
188
|
+
got: e.got,
|
|
189
|
+
hint: e.hint,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
160
192
|
if (e?.code === "CONFLICT") {
|
|
161
193
|
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
162
194
|
error_type: "conflict",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-scope enforcement on the MCP `list-tags` single-tag path (vault#550
|
|
3
|
+
* fold; also closes vault#560). Core's list-tags is scope-unaware by
|
|
4
|
+
* architecture — `applyTagScopeWrappers` in src/mcp-tools.ts is the
|
|
5
|
+
* enforcement layer. Before this fix the wrapper only filtered ARRAY
|
|
6
|
+
* results, so the single-tag (`{tag}` param) shape passed through
|
|
7
|
+
* unwrapped, leaking (a) the FULL record of an existing out-of-scope tag
|
|
8
|
+
* (#560), and (b) post-#550, a vault-wide `did_you_mean` suggestion that
|
|
9
|
+
* could name an out-of-scope tag.
|
|
10
|
+
*
|
|
11
|
+
* Fixture: PARACHUTE_HOME temp home + a hand-built AuthResult (no JWT
|
|
12
|
+
* infra needed — `generateScopedMcpTools` takes the resolved auth
|
|
13
|
+
* directly), same pattern as attribution-threading.test.ts's MCP section.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
16
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { writeVaultConfig } from "./config.ts";
|
|
20
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
21
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
22
|
+
import type { AuthResult } from "./auth.ts";
|
|
23
|
+
|
|
24
|
+
let tmpHome: string;
|
|
25
|
+
let prevHome: string | undefined;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tmpHome = join(tmpdir(), `vault-listtags-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
29
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
30
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
31
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
32
|
+
clearVaultStoreCache();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
clearVaultStoreCache();
|
|
37
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
38
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
39
|
+
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function seedVault(name: string): void {
|
|
43
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
44
|
+
getVaultStore(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
|
|
48
|
+
return {
|
|
49
|
+
permission: "full",
|
|
50
|
+
scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
|
|
51
|
+
legacyDerived: false,
|
|
52
|
+
scoped_tags: scopedTags,
|
|
53
|
+
vault_name: null,
|
|
54
|
+
caller_jti: null,
|
|
55
|
+
actor: "test-user",
|
|
56
|
+
via: "api",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function listTagsTool(vaultName: string, scopedTags: string[] | null) {
|
|
61
|
+
const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
|
|
62
|
+
return tools.find((t) => t.name === "list-tags")!;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("MCP list-tags single-tag path — tag-scope enforcement (vault#550 fold, closes #560)", () => {
|
|
66
|
+
test("scoped token + EXISTING out-of-scope tag → tag_not_found with no record fields, no did_you_mean (#560's full-record leak)", async () => {
|
|
67
|
+
seedVault("journal");
|
|
68
|
+
const store = getVaultStore("journal");
|
|
69
|
+
await store.upsertTagRecord("work", { description: "top secret reorg planning" });
|
|
70
|
+
await store.createNote("w", { tags: ["work"] });
|
|
71
|
+
await store.createNote("h", { tags: ["health"] });
|
|
72
|
+
|
|
73
|
+
const tool = await listTagsTool("journal", ["health"]);
|
|
74
|
+
const result = (await tool.execute({ tag: "work" })) as any;
|
|
75
|
+
|
|
76
|
+
expect(result.error_type).toBe("tag_not_found");
|
|
77
|
+
expect(result.tag).toBe("work");
|
|
78
|
+
// No leak: none of the record's fields, no counts, no suggestion.
|
|
79
|
+
expect(result.description).toBeUndefined();
|
|
80
|
+
expect(result.count).toBeUndefined();
|
|
81
|
+
expect(result.expanded_count).toBeUndefined();
|
|
82
|
+
expect(result.fields).toBeUndefined();
|
|
83
|
+
expect(result.parent_names).toBeUndefined();
|
|
84
|
+
expect(result.did_you_mean).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("scoped token + NONEXISTENT tag → tag_not_found without an out-of-scope did_you_mean", async () => {
|
|
88
|
+
seedVault("journal");
|
|
89
|
+
const store = getVaultStore("journal");
|
|
90
|
+
// "project" exists but is OUT of scope — it must not surface as a
|
|
91
|
+
// suggestion for the near-miss "projet".
|
|
92
|
+
await store.upsertTagRecord("project", {});
|
|
93
|
+
await store.createNote("h", { tags: ["health"] });
|
|
94
|
+
|
|
95
|
+
const tool = await listTagsTool("journal", ["health"]);
|
|
96
|
+
// "projet" is not in the allowlist → short-circuits to tag_not_found
|
|
97
|
+
// before core even computes a suggestion.
|
|
98
|
+
const result = (await tool.execute({ tag: "projet" })) as any;
|
|
99
|
+
expect(result.error_type).toBe("tag_not_found");
|
|
100
|
+
expect(result.tag).toBe("projet");
|
|
101
|
+
expect(result.did_you_mean).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("scoped token + in-scope miss keeps did_you_mean only when the suggestion is also in-scope", async () => {
|
|
105
|
+
seedVault("journal");
|
|
106
|
+
const store = getVaultStore("journal");
|
|
107
|
+
// Declared hierarchy: health/food is a child of health, so the
|
|
108
|
+
// allowlist expansion covers both. A near-miss on the CHILD name is in
|
|
109
|
+
// the allowlist's expansion? No — "health/fod" itself isn't a known
|
|
110
|
+
// tag, but it IS within scope only if the allowlist contains it.
|
|
111
|
+
// expandTokenTagScope(["health"]) = {health, health/food}; the literal
|
|
112
|
+
// input "health/fod" is NOT in that set → short-circuit tag_not_found
|
|
113
|
+
// (no suggestion) — pinning that the wrapper gates on the allowlist,
|
|
114
|
+
// not on string prefixes.
|
|
115
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
116
|
+
await store.createNote("hf", { tags: ["health/food"] });
|
|
117
|
+
|
|
118
|
+
const tool = await listTagsTool("journal", ["health"]);
|
|
119
|
+
const result = (await tool.execute({ tag: "health/fod" })) as any;
|
|
120
|
+
expect(result.error_type).toBe("tag_not_found");
|
|
121
|
+
expect(result.did_you_mean).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("unscoped token unchanged — full record for existing tags, did_you_mean on misses", async () => {
|
|
125
|
+
seedVault("journal");
|
|
126
|
+
const store = getVaultStore("journal");
|
|
127
|
+
await store.upsertTagRecord("project", { description: "projects" });
|
|
128
|
+
await store.createNote("p", { tags: ["project"] });
|
|
129
|
+
|
|
130
|
+
const tool = await listTagsTool("journal", null);
|
|
131
|
+
|
|
132
|
+
const hit = (await tool.execute({ tag: "project" })) as any;
|
|
133
|
+
expect(hit.name).toBe("project");
|
|
134
|
+
expect(hit.count).toBe(1);
|
|
135
|
+
expect(hit.description).toBe("projects");
|
|
136
|
+
|
|
137
|
+
const miss = (await tool.execute({ tag: "projet" })) as any;
|
|
138
|
+
expect(miss.error_type).toBe("tag_not_found");
|
|
139
|
+
expect(miss.did_you_mean).toBe("project");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("scoped token + array list still filters to the allowlist (regression: pre-existing behavior)", async () => {
|
|
143
|
+
seedVault("journal");
|
|
144
|
+
const store = getVaultStore("journal");
|
|
145
|
+
await store.createNote("h", { tags: ["health"] });
|
|
146
|
+
await store.createNote("w", { tags: ["work"] });
|
|
147
|
+
|
|
148
|
+
const tool = await listTagsTool("journal", ["health"]);
|
|
149
|
+
const result = (await tool.execute({})) as any[];
|
|
150
|
+
const names = result.map((t) => t.name);
|
|
151
|
+
expect(names).toContain("health");
|
|
152
|
+
expect(names).not.toContain("work");
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/mcp-tools.ts
CHANGED
|
@@ -308,9 +308,11 @@ function applyTagScopeWrappers(
|
|
|
308
308
|
const allowed = await getAllowed();
|
|
309
309
|
const result = await orig(params);
|
|
310
310
|
if (!allowed) return result;
|
|
311
|
-
//
|
|
312
|
-
// - Array (legacy list, no cursor)
|
|
311
|
+
// Possible response shapes (vault#550 added the `warnings` variants):
|
|
312
|
+
// - Array (legacy list, no cursor, no warnings)
|
|
313
313
|
// - `{notes, next_cursor}` (cursor mode, vault#313)
|
|
314
|
+
// - `{notes, warnings}` (warnings present, not in cursor mode)
|
|
315
|
+
// - `{notes, next_cursor, warnings}` (both)
|
|
314
316
|
// - `{...note}` with `id`+`tags` (single-note by id)
|
|
315
317
|
if (Array.isArray(result)) {
|
|
316
318
|
return result
|
|
@@ -321,15 +323,22 @@ function applyTagScopeWrappers(
|
|
|
321
323
|
result &&
|
|
322
324
|
typeof result === "object" &&
|
|
323
325
|
"notes" in result &&
|
|
324
|
-
Array.isArray((result as any).notes)
|
|
325
|
-
"next_cursor" in result
|
|
326
|
+
Array.isArray((result as any).notes)
|
|
326
327
|
) {
|
|
327
|
-
const r = result as { notes: any[]; next_cursor
|
|
328
|
+
const r = result as { notes: any[]; next_cursor?: string | null; warnings?: unknown };
|
|
328
329
|
return {
|
|
329
330
|
notes: r.notes
|
|
330
331
|
.filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
|
|
331
332
|
.map(scrubNoteLinks),
|
|
332
|
-
next_cursor: r.next_cursor,
|
|
333
|
+
...("next_cursor" in r ? { next_cursor: r.next_cursor } : {}),
|
|
334
|
+
// `warnings` intentionally DROPPED for a tag-scoped session: core's
|
|
335
|
+
// `collectUnknownTagWarnings` (core/src/query-warnings.ts) resolves
|
|
336
|
+
// `did_you_mean` against the FULL vault-wide tag catalog, which
|
|
337
|
+
// would leak an out-of-scope tag's existence/name to a token that
|
|
338
|
+
// can't otherwise see it — the same "no leak across the scope
|
|
339
|
+
// boundary" stance this file takes everywhere else (see
|
|
340
|
+
// docs/contracts/tag-scoped-tokens.md). Unscoped sessions keep
|
|
341
|
+
// `warnings` untouched via the `!allowed` early return above.
|
|
333
342
|
};
|
|
334
343
|
}
|
|
335
344
|
if (result && typeof result === "object" && "id" in result && "tags" in result) {
|
|
@@ -342,9 +351,41 @@ function applyTagScopeWrappers(
|
|
|
342
351
|
|
|
343
352
|
wrapReadTool(tools, "list-tags", async (orig, params) => {
|
|
344
353
|
const allowed = await getAllowed();
|
|
354
|
+
if (!allowed) return await orig(params);
|
|
355
|
+
// Single-tag detail (`{tag}` param) — the non-array shape the old
|
|
356
|
+
// wrapper silently passed through, which leaked BOTH the full record of
|
|
357
|
+
// an existing out-of-scope tag (vault#560) AND, post-#550, a vault-wide
|
|
358
|
+
// `did_you_mean` suggestion for a nonexistent one. This wrapper is the
|
|
359
|
+
// enforcement layer for tag scope on this tool; core's list-tags stays
|
|
360
|
+
// scope-unaware by architecture (same division as every other wrapper
|
|
361
|
+
// in this file).
|
|
362
|
+
const singleTag = typeof (params as any).tag === "string" ? ((params as any).tag as string) : null;
|
|
363
|
+
if (singleTag !== null && !allowed.has(singleTag)) {
|
|
364
|
+
// Out-of-scope name — whether the tag exists or not. Same
|
|
365
|
+
// "tag_not_found, no leak" shape as the REST handlers' early-return,
|
|
366
|
+
// and deliberately NO did_you_mean (any suggestion would be computed
|
|
367
|
+
// vault-wide). Short-circuits BEFORE core runs, so the full record /
|
|
368
|
+
// suggestion is never even computed.
|
|
369
|
+
return { error: "Tag not found", error_type: "tag_not_found", tag: singleTag };
|
|
370
|
+
}
|
|
345
371
|
const result = await orig(params);
|
|
346
|
-
if (
|
|
347
|
-
|
|
372
|
+
if (Array.isArray(result)) {
|
|
373
|
+
return result.filter((t: any) => allowed.has(t.name));
|
|
374
|
+
}
|
|
375
|
+
// In-scope single-tag miss (nonexistent but allowlisted name): core's
|
|
376
|
+
// tag_not_found may carry a vault-wide `did_you_mean` — keep it only
|
|
377
|
+
// when the suggestion itself is inside the allowlist.
|
|
378
|
+
if (
|
|
379
|
+
result &&
|
|
380
|
+
typeof result === "object" &&
|
|
381
|
+
(result as any).error_type === "tag_not_found" &&
|
|
382
|
+
typeof (result as any).did_you_mean === "string" &&
|
|
383
|
+
!allowed.has((result as any).did_you_mean)
|
|
384
|
+
) {
|
|
385
|
+
const { did_you_mean: _dropped, ...scrubbed } = result as Record<string, unknown>;
|
|
386
|
+
return scrubbed;
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
348
389
|
});
|
|
349
390
|
|
|
350
391
|
wrapReadTool(tools, "find-path", async (orig, params) => {
|