@openparachute/vault 0.7.0-rc.2 → 0.7.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-http.ts CHANGED
@@ -139,13 +139,18 @@ async function handleMcp(
139
139
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
140
140
  };
141
141
  } catch (err) {
142
- // Domain errors from the core tools (conflict, missing precondition) get
143
- // surfaced as JSON-RPC errors with a structured `data` field so an
144
- // agent can key off `data.error_type` and the concurrency tokens.
145
- // Everything else falls through to an in-band tool error with
146
- // `isError: true` legible but unstructured.
142
+ // Domain errors from the core tools (conflict, missing precondition,
143
+ // path collisions, batch caps, cursor/query validation, tag-field
144
+ // conflicts, ...) get surfaced as JSON-RPC errors with a structured
145
+ // `data` field so an agent can key off `data.error_type` and any
146
+ // error-specific fields (concurrency tokens, violations, candidates,
147
+ // ...). vault#554: every domain error class is mapped below, either by
148
+ // a dedicated branch (full field fidelity) or the generic `error_type`
149
+ // catch-all near the end. Only a TRULY unknown error (no `error_type`
150
+ // anywhere on it) falls through to the in-band `isError: true` text.
147
151
  const message = err instanceof Error ? err.message : "Unknown error";
148
152
  const e = err as {
153
+ name?: string;
149
154
  code?: string;
150
155
  note_id?: string;
151
156
  note_path?: string | null;
@@ -159,6 +164,12 @@ async function handleMcp(
159
164
  error_type?: string;
160
165
  got?: unknown;
161
166
  hint?: string;
167
+ path?: string;
168
+ candidates?: unknown;
169
+ extension?: string;
170
+ reason?: string;
171
+ limit?: number;
172
+ tag?: string;
162
173
  };
163
174
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
164
175
  // values that are structurally invalid rather than merely "no
@@ -176,6 +187,19 @@ async function handleMcp(
176
187
  hint: e.hint,
177
188
  });
178
189
  }
190
+ // Advanced-mode full-text search syntax error (vault#551) — a
191
+ // raw-passthrough FTS5 query that FTS5 itself rejected. Same shape as
192
+ // `invalid_query` (field/got/hint) but a DISTINCT `error_type` so a
193
+ // caller can tell "your search_mode:\"advanced\" syntax is malformed"
194
+ // apart from "your query PARAMS are malformed."
195
+ if (e?.error_type === "invalid_search_syntax") {
196
+ throw new McpError(ErrorCode.InvalidParams, message, {
197
+ error_type: "invalid_search_syntax",
198
+ field: e.field,
199
+ got: e.got,
200
+ hint: e.hint,
201
+ });
202
+ }
179
203
  if (e?.code === "CONFLICT") {
180
204
  throw new McpError(ErrorCode.InvalidRequest, message, {
181
205
  error_type: "conflict",
@@ -183,6 +207,7 @@ async function handleMcp(
183
207
  your_updated_at: e.expected_updated_at,
184
208
  path: e.note_path ?? null,
185
209
  note_id: e.note_id,
210
+ hint: "re-read the note (query-notes) and re-apply your change against its current updated_at, or pass force: true to overwrite",
186
211
  });
187
212
  }
188
213
  // State-transition compare-and-set conflict (vault#299 Part B) — a
@@ -197,6 +222,7 @@ async function handleMcp(
197
222
  expected_from: e.expected_from,
198
223
  to: e.to,
199
224
  current: e.current ?? null,
225
+ hint: "re-read the note's current value for this field and retry the transition from its actual current state",
200
226
  });
201
227
  }
202
228
  // Strict-schema rejection (vault#299 Part A) — one error carrying ALL
@@ -205,6 +231,7 @@ async function handleMcp(
205
231
  throw new McpError(ErrorCode.InvalidParams, message, {
206
232
  error_type: "schema_validation",
207
233
  violations: e.violations ?? [],
234
+ hint: "fix every field listed in violations and retry — none of this write was applied",
208
235
  });
209
236
  }
210
237
  if (e?.code === "PRECONDITION_REQUIRED") {
@@ -212,6 +239,96 @@ async function handleMcp(
212
239
  error_type: "precondition_required",
213
240
  note_id: e.note_id,
214
241
  path: e.note_path ?? null,
242
+ hint: "re-read the note, pass its updated_at as if_updated_at, or pass force: true to skip the check",
243
+ });
244
+ }
245
+ // Any other QueryError (vault#554) — FIELD_NOT_INDEXED, UNKNOWN_OPERATOR,
246
+ // INVALID_OPERATOR_VALUE, and the various cursor/near/search
247
+ // incompatibility throws that predate the vault#550/#551 `error_type`
248
+ // convention (which is why they weren't caught by the two branches
249
+ // above). Falls back to `error_type: "invalid_query"` — the umbrella
250
+ // REST already uses for this whole error class — so these no longer
251
+ // fall through to the unstructured `isError: true` text.
252
+ if (e?.name === "QueryError") {
253
+ throw new McpError(ErrorCode.InvalidParams, message, {
254
+ error_type: e.error_type ?? "invalid_query",
255
+ code: e.code,
256
+ field: e.field,
257
+ got: e.got,
258
+ hint: e.hint,
259
+ });
260
+ }
261
+ // Malformed / stale opaque cursor (vault#313, vault#554) — was
262
+ // entirely unmapped here before (REST already returns `{error, code}`
263
+ // — no `error_type` either; both surfaces get one now). `code` is
264
+ // already the exact `error_type` vocabulary: "cursor_invalid" or
265
+ // "cursor_query_mismatch".
266
+ if (e?.name === "CursorError" && typeof e.code === "string") {
267
+ throw new McpError(ErrorCode.InvalidParams, message, {
268
+ error_type: e.code,
269
+ });
270
+ }
271
+ // Path-rename/create collision (vault#126, vault#554) — schema's
272
+ // UNIQUE(path) tripped. Mirrors REST's 409 `path_conflict` shape.
273
+ if (e?.code === "PATH_CONFLICT") {
274
+ throw new McpError(ErrorCode.InvalidRequest, message, {
275
+ error_type: "path_conflict",
276
+ path: e.path,
277
+ });
278
+ }
279
+ // A path lookup matched more than one note (vault#330 S1, vault#554) —
280
+ // mirrors REST's 409 `ambiguous_path` shape (candidates = the
281
+ // disambiguating extensions).
282
+ if (e?.code === "AMBIGUOUS_PATH") {
283
+ throw new McpError(ErrorCode.InvalidRequest, message, {
284
+ error_type: "ambiguous_path",
285
+ path: e.path,
286
+ candidates: e.candidates,
287
+ });
288
+ }
289
+ // Bad `extension` value (vault#328, vault#554) — mirrors REST's 400
290
+ // `invalid_extension` shape.
291
+ if (e?.code === "INVALID_EXTENSION") {
292
+ throw new McpError(ErrorCode.InvalidParams, message, {
293
+ error_type: "invalid_extension",
294
+ extension: e.extension,
295
+ reason: e.reason,
296
+ });
297
+ }
298
+ // Batch cap exceeded (#213, vault#554) — mirrors REST's 413
299
+ // `batch_too_large` shape.
300
+ if (e?.code === "BATCH_TOO_LARGE") {
301
+ throw new McpError(ErrorCode.InvalidRequest, message, {
302
+ error_type: "batch_too_large",
303
+ limit: e.limit,
304
+ got: e.got,
305
+ });
306
+ }
307
+ // update-tag cross-tag field conflicts (vault#553/#554) — carries
308
+ // EVERY violation in one response (see `TagFieldConflictError`).
309
+ if (e?.code === "TAG_FIELD_CONFLICT") {
310
+ throw new McpError(ErrorCode.InvalidParams, message, {
311
+ error_type: "tag_field_conflict",
312
+ tag: e.tag,
313
+ violations: e.violations ?? [],
314
+ });
315
+ }
316
+ // Generic catch-all (vault#554): any remaining error that carries a
317
+ // stable `error_type` — either a domain error class that stamps it as
318
+ // an instance property (IndexedFieldError, and the classes handled by
319
+ // dedicated branches above as defense-in-depth) or a validation leaf
320
+ // built with `structuredError()` in core/src/mcp.ts (mutually_exclusive,
321
+ // invalid_content_edit, content_edit_not_found, content_edit_ambiguous,
322
+ // invalid_state_transition, invalid_parent_names, invalid_relationships,
323
+ // not_found, ...). This is the backstop that makes "nothing falls
324
+ // through to the unstructured isError text except a TRULY unknown
325
+ // error" true by construction: only errors nobody tagged with
326
+ // `error_type` still hit the fallback below.
327
+ if (typeof e?.error_type === "string") {
328
+ throw new McpError(ErrorCode.InvalidParams, message, {
329
+ error_type: e.error_type,
330
+ field: e.field,
331
+ hint: e.hint,
215
332
  });
216
333
  }
217
334
  return {
@@ -151,4 +151,40 @@ describe("MCP list-tags single-tag path — tag-scope enforcement (vault#550 fol
151
151
  expect(names).toContain("health");
152
152
  expect(names).not.toContain("work");
153
153
  });
154
+
155
+ // vault#554 carry-forward: the wrapper's SECOND scrub branch (the one
156
+ // AFTER the early out-of-scope short-circuit above) fires when the queried
157
+ // tag itself IS in the allowlist but core still reports tag_not_found —
158
+ // the hollow-tag case (vault#550: no identity row, no notes directly
159
+ // carrying it) reached ONLY via a child's `parent_names`. Core computes
160
+ // `did_you_mean` vault-wide (scope-unaware by architecture), so it can
161
+ // name an out-of-scope tag; the wrapper must strip that suggestion even
162
+ // though the queried tag itself passed the allowlist gate. This branch was
163
+ // execution-verified in a prior review; this test commits it.
164
+ test("scoped token + in-scope HOLLOW tag (reachable only via a child's parent_names) scrubs an out-of-scope did_you_mean", async () => {
165
+ seedVault("journal");
166
+ const store = getVaultStore("journal");
167
+
168
+ // "health" itself has NO identity row and NO note directly carrying it —
169
+ // it's in the allowlist expansion ONLY because "kale" declares it as a
170
+ // parent. "kale" is deliberately NOT lexically close to "health" (the
171
+ // mission's "non-close child") so it can't win did_you_mean and mask
172
+ // the bug this test targets.
173
+ await store.upsertTagRecord("kale", { parent_names: ["health"] });
174
+ await store.createNote("k", { tags: ["kale"] });
175
+
176
+ // Out-of-scope decoy, lexically close to "health" (prefix match) — this
177
+ // is what core's vault-wide suggestSimilarTag would pick if nothing
178
+ // scrubbed it.
179
+ await store.createNote("hy", { tags: ["healthy"] });
180
+
181
+ const tool = await listTagsTool("journal", ["health"]);
182
+ const result = (await tool.execute({ tag: "health" })) as any;
183
+
184
+ expect(result.error_type).toBe("tag_not_found");
185
+ expect(result.tag).toBe("health");
186
+ // The scrub: did_you_mean must NOT leak the out-of-scope "healthy",
187
+ // even though "health" (the query) itself passed the allowlist gate.
188
+ expect(result.did_you_mean).toBeUndefined();
189
+ });
154
190
  });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Combined tag-scope × full-text-search enforcement (vault#554 carry-forward
3
+ * — "auth-561" review traced the code clean; this commits the self-verifying
4
+ * test the review promised).
5
+ *
6
+ * `applyTagScopeWrappers`'s `query-notes` wrapper (`src/mcp-tools.ts`) runs
7
+ * the ENTIRE search — content matching, `search_mode` escaping, `sort` — via
8
+ * core (scope-unaware by architecture), then filters the returned notes
9
+ * against the token's allowlist as a post-hoc array `.filter()`
10
+ * (`noteWithinTagScope`, `src/tag-scope.ts`). Because the filter is applied
11
+ * uniformly AFTER core returns, in principle no single (mode, sort)
12
+ * combination is special — but a regression in one code path (e.g. a search
13
+ * branch that bypasses the wrapper, or a shape the filter doesn't recognize)
14
+ * could still slip an out-of-scope note through for SOME combination. This
15
+ * test exercises the full `search_mode × sort` matrix and asserts the
16
+ * out-of-scope note never appears in any of them.
17
+ *
18
+ * Fixture: two notes both match the search term "widget" in punctuation-
19
+ * bearing content (so literal-mode escaping and advanced-mode raw FTS5 both
20
+ * still match it) — one tagged in-scope (`health`), one out-of-scope
21
+ * (`work`). A tag-scoped session ["health"] must see only the in-scope note
22
+ * across every (search_mode, sort) pairing.
23
+ */
24
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
25
+ import { mkdirSync, rmSync, existsSync } from "fs";
26
+ import { join } from "path";
27
+ import { tmpdir } from "os";
28
+ import { writeVaultConfig } from "./config.ts";
29
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
30
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
31
+ import type { AuthResult } from "./auth.ts";
32
+ import { SEARCH_MODES } from "../core/src/search-query.ts";
33
+
34
+ let tmpHome: string;
35
+ let prevHome: string | undefined;
36
+
37
+ beforeEach(() => {
38
+ tmpHome = join(tmpdir(), `vault-search-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
39
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
40
+ prevHome = process.env.PARACHUTE_HOME;
41
+ process.env.PARACHUTE_HOME = tmpHome;
42
+ clearVaultStoreCache();
43
+ });
44
+
45
+ afterEach(() => {
46
+ clearVaultStoreCache();
47
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
48
+ else process.env.PARACHUTE_HOME = prevHome;
49
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
50
+ });
51
+
52
+ function seedVault(name: string): void {
53
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
54
+ getVaultStore(name);
55
+ }
56
+
57
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
58
+ return {
59
+ permission: "full",
60
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
61
+ legacyDerived: false,
62
+ scoped_tags: scopedTags,
63
+ vault_name: null,
64
+ caller_jti: null,
65
+ actor: "test-user",
66
+ via: "api",
67
+ };
68
+ }
69
+
70
+ async function queryNotesTool(vaultName: string, scopedTags: string[] | null) {
71
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
72
+ return tools.find((t) => t.name === "query-notes")!;
73
+ }
74
+
75
+ // Normalize the tool's response (bare array, or {notes, next_cursor?,
76
+ // warnings?} in cursor/warnings mode) down to the note-id list actually
77
+ // returned — search is never cursor mode, but stays defensive to shape.
78
+ function idsOf(result: unknown): string[] {
79
+ if (Array.isArray(result)) return result.map((n: any) => n.id);
80
+ const notes = (result as any)?.notes;
81
+ return Array.isArray(notes) ? notes.map((n: any) => n.id) : [];
82
+ }
83
+
84
+ describe("MCP query-notes search × tag-scope — combined enforcement (vault#554 carry-forward)", () => {
85
+ test("out-of-scope notes never appear across every search_mode × sort combination", async () => {
86
+ seedVault("journal");
87
+ const store = getVaultStore("journal");
88
+
89
+ // Punctuation-bearing content so literal-mode's escaping and
90
+ // advanced-mode's raw FTS5 passthrough both still match "widget"
91
+ // cleanly (advanced mode has no operator characters to trip on here).
92
+ const inScope = await store.createNote("didn't fix the widget yet", { tags: ["health"] });
93
+ const outOfScope = await store.createNote("ordered a replacement widget", { tags: ["work"] });
94
+
95
+ const tool = await queryNotesTool("journal", ["health"]);
96
+
97
+ const sorts: (undefined | "asc" | "desc")[] = [undefined, "asc", "desc"];
98
+ const modes: (undefined | (typeof SEARCH_MODES)[number])[] = [undefined, ...SEARCH_MODES];
99
+
100
+ for (const search_mode of modes) {
101
+ for (const sort of sorts) {
102
+ const params: Record<string, unknown> = { search: "widget" };
103
+ if (search_mode !== undefined) params.search_mode = search_mode;
104
+ if (sort !== undefined) params.sort = sort;
105
+
106
+ const result = await tool.execute(params);
107
+ const ids = idsOf(result);
108
+
109
+ expect(ids).toContain(inScope.id);
110
+ expect(ids).not.toContain(outOfScope.id);
111
+ }
112
+ }
113
+ });
114
+
115
+ test("unscoped session sees both notes across the same matrix (control)", async () => {
116
+ seedVault("journal");
117
+ const store = getVaultStore("journal");
118
+
119
+ const a = await store.createNote("didn't fix the widget yet", { tags: ["health"] });
120
+ const b = await store.createNote("ordered a replacement widget", { tags: ["work"] });
121
+
122
+ const tool = await queryNotesTool("journal", null);
123
+
124
+ for (const search_mode of [undefined, ...SEARCH_MODES]) {
125
+ for (const sort of [undefined, "asc", "desc"] as const) {
126
+ const params: Record<string, unknown> = { search: "widget" };
127
+ if (search_mode !== undefined) params.search_mode = search_mode;
128
+ if (sort !== undefined) params.sort = sort;
129
+
130
+ const ids = idsOf(await tool.execute(params));
131
+ expect(ids).toContain(a.id);
132
+ expect(ids).toContain(b.id);
133
+ }
134
+ }
135
+ });
136
+ });
package/src/mcp-tools.ts CHANGED
@@ -22,8 +22,12 @@ import {
22
22
  expandTokenTagScope,
23
23
  filterHydratedLinksByTagScope,
24
24
  noteWithinTagScope,
25
+ scrubIndexedFieldConflictError,
26
+ scrubTagFieldViolationsByScope,
25
27
  tagsWithinScope,
26
28
  } from "./tag-scope.ts";
29
+ import { TagFieldConflictError } from "../core/src/tag-schemas.ts";
30
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
27
31
  import {
28
32
  findTokensReferencingTag,
29
33
  recordMcpMintLedger,
@@ -344,7 +348,7 @@ function applyTagScopeWrappers(
344
348
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
345
349
  return noteWithinTagScope(result as any, allowed, rawTags)
346
350
  ? scrubNoteLinks(result)
347
- : { error: "Note not found", id: (result as any).id };
351
+ : { error: "Note not found", error_type: "not_found", id: (result as any).id };
348
352
  }
349
353
  return result;
350
354
  });
@@ -470,7 +474,7 @@ function applyTagScopeWrappers(
470
474
  if (!id) continue;
471
475
  const existing = await store.getNote(id as string);
472
476
  if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
473
- return { error: "Note not found", id };
477
+ return { error: "Note not found", error_type: "not_found", id };
474
478
  }
475
479
  const removed = new Set<string>((item as any).tags?.remove ?? []);
476
480
  const projected = new Set<string>((existing.tags ?? []).filter((t) => !removed.has(t)));
@@ -489,7 +493,7 @@ function applyTagScopeWrappers(
489
493
  if (id) {
490
494
  const existing = await store.getNote(id as string);
491
495
  if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
492
- return { error: "Note not found", id };
496
+ return { error: "Note not found", error_type: "not_found", id };
493
497
  }
494
498
  }
495
499
  return await orig(params);
@@ -502,7 +506,37 @@ function applyTagScopeWrappers(
502
506
  if (typeof tag === "string" && !allowed.has(tag)) {
503
507
  return forbidden(`update-tag: tag "${tag}" is outside the token's allowlist`);
504
508
  }
505
- return await orig(params);
509
+ try {
510
+ return await orig(params);
511
+ } catch (err: any) {
512
+ // Tag-scope scrub for cross-tag field conflicts (vault#554
513
+ // auth-and-scope fold). Core's validation scans EVERY tag's schema
514
+ // (scope-unaware by architecture), so its TagFieldConflictError can
515
+ // name an OUT-OF-SCOPE tag and reveal its declared type/flag in both
516
+ // the violation entries and the error message. The write stays
517
+ // rejected — schema integrity is scope-independent — but re-throw
518
+ // with scrubbed violations (out-of-scope declarers generalized, no
519
+ // tag name / declared type, `other_tag` dropped; in-scope declarers
520
+ // keep full detail). Rebuilding via the constructor also rebuilds
521
+ // the top-level message from the scrubbed entries. Same pattern as
522
+ // the list-tags `did_you_mean` scrub above.
523
+ if (err && err.code === "TAG_FIELD_CONFLICT" && Array.isArray(err.violations)) {
524
+ throw new TagFieldConflictError(
525
+ err.tag ?? (tag as string),
526
+ scrubTagFieldViolationsByScope(err.violations, allowed),
527
+ );
528
+ }
529
+ // Same leak through the OTHER door (wire-review interaction): a
530
+ // both-indexed cross-tag type conflict deliberately bypasses the
531
+ // pre-check (preserving its declareField → invalid_indexed_field
532
+ // contract), and declareField's IndexedFieldError message names the
533
+ // other declarer tag(s). Generalize for scoped callers; solo
534
+ // own-field IndexedFieldErrors (no declarer_tags) pass untouched.
535
+ if (err instanceof IndexedFieldError) {
536
+ throw scrubIndexedFieldConflictError(err, allowed);
537
+ }
538
+ throw err;
539
+ }
506
540
  });
507
541
 
508
542
  wrapReadTool(tools, "delete-tag", async (orig, params) => {
@@ -542,6 +576,16 @@ function overrideVaultInfo(
542
576
  if (!vaultInfo) return;
543
577
 
544
578
  vaultInfo.execute = async (params) => {
579
+ // NOTE (vault#554): these two throws are deliberately left as plain,
580
+ // unstructured `Error`s — NOT given `error_type` — even though the rest
581
+ // of this file's sweep attaches one everywhere else. Attaching one here
582
+ // routes the throw through mcp-http.ts's structured-error mapping, which
583
+ // surfaces it as a JSON-RPC protocol-level error (`response.error`)
584
+ // instead of the in-band tool result the existing contract test asserts
585
+ // (`response.result.isError === true`, `response.result.content[0].text`
586
+ // containing the scope name) — see "tools/call of vault-info with
587
+ // description arg and vault:read scope is refused" in src/vault.test.ts.
588
+ // Changing that transport shape is out of scope for this wave.
545
589
  const config = readVaultConfig(vaultName);
546
590
  if (!config) throw new Error(`Vault "${vaultName}" not found`);
547
591