@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.9

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.
@@ -1,6 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import type { Note } from "./types.js";
2
3
  import * as linkOps from "./links.js";
4
+ import { getNote } from "./notes.js";
3
5
  import { chunkForInClause } from "./sql-in.js";
6
+ import { transaction } from "./txn.js";
4
7
 
5
8
  // ---------------------------------------------------------------------------
6
9
  // Parser — extract [[wikilinks]] from markdown content
@@ -251,19 +254,28 @@ export interface UnresolvedWikilink {
251
254
  source_id: string;
252
255
  source_path?: string;
253
256
  target_path: string;
257
+ /**
258
+ * Link relationship this pending entry will materialize as once
259
+ * resolved. `"wikilink"` for content-parsed `[[targets]]`; the
260
+ * caller's own relationship string for structured `links` forward-refs
261
+ * (vault#555). Always present post-migration; defaults to `"wikilink"`
262
+ * for rows written before the column existed.
263
+ */
264
+ relationship: string;
254
265
  }
255
266
 
256
267
  /**
257
268
  * List unresolved wikilinks across the vault.
258
269
  */
259
270
  export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved: UnresolvedWikilink[]; count: number } {
271
+ ensureRelationshipColumn(db);
260
272
  let total: number;
261
- let rows: { source_id: string; target_path: string }[];
273
+ let rows: { source_id: string; target_path: string; relationship: string }[];
262
274
  try {
263
275
  total = (db.prepare("SELECT COUNT(*) as c FROM unresolved_wikilinks").get() as { c: number }).c;
264
276
  rows = db.prepare(
265
- "SELECT source_id, target_path FROM unresolved_wikilinks ORDER BY source_id LIMIT ?",
266
- ).all(limit) as { source_id: string; target_path: string }[];
277
+ "SELECT source_id, target_path, relationship FROM unresolved_wikilinks ORDER BY source_id LIMIT ?",
278
+ ).all(limit) as { source_id: string; target_path: string; relationship: string }[];
267
279
  } catch {
268
280
  // Table doesn't exist yet
269
281
  return { unresolved: [], count: 0 };
@@ -288,11 +300,56 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
288
300
  source_id: r.source_id,
289
301
  source_path: pathMap.get(r.source_id) ?? undefined,
290
302
  target_path: r.target_path,
303
+ relationship: r.relationship || WIKILINK_REL,
291
304
  }));
292
305
 
293
306
  return { unresolved, count: total };
294
307
  }
295
308
 
309
+ /** One note's dangling outbound link, as surfaced on a note read (vault#555). */
310
+ export interface BrokenLink {
311
+ target: string;
312
+ relationship: string;
313
+ }
314
+
315
+ /**
316
+ * Batch-fetch each note's pending `unresolved_wikilinks` rows — the
317
+ * `include_broken_links` surfacing on `query-notes` / `GET /notes`. ONE
318
+ * query for the whole page (mirrors `getLinksHydratedForNotes`'s batching),
319
+ * not one per note. Returns a map with an entry (possibly `[]`) for every
320
+ * requested id whenever the table exists; when the table has never been
321
+ * created (no note in this vault has ever had a broken link) every id maps
322
+ * to `[]` without a query attempt.
323
+ */
324
+ export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map<string, BrokenLink[]> {
325
+ const result = new Map<string, BrokenLink[]>(noteIds.map((id) => [id, []]));
326
+ if (noteIds.length === 0) return result;
327
+
328
+ const rows: { source_id: string; target_path: string; relationship: string }[] = [];
329
+ try {
330
+ ensureRelationshipColumn(db);
331
+ for (const chunk of chunkForInClause(noteIds)) {
332
+ const placeholders = chunk.map(() => "?").join(", ");
333
+ rows.push(...db.prepare(
334
+ `SELECT source_id, target_path, relationship FROM unresolved_wikilinks WHERE source_id IN (${placeholders})`,
335
+ ).all(...chunk) as typeof rows);
336
+ }
337
+ } catch {
338
+ // Table doesn't exist — every id already maps to [] above.
339
+ return result;
340
+ }
341
+
342
+ for (const row of rows) {
343
+ result.get(row.source_id)?.push({ target: row.target_path, relationship: row.relationship || WIKILINK_REL });
344
+ }
345
+ return result;
346
+ }
347
+
348
+ /** Single-note convenience wrapper around {@link getUnresolvedLinksForNotes}. */
349
+ export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenLink[] {
350
+ return getUnresolvedLinksForNotes(db, [noteId]).get(noteId) ?? [];
351
+ }
352
+
296
353
  // ---------------------------------------------------------------------------
297
354
  // Sync — maintain wikilink-based links for a note
298
355
  // ---------------------------------------------------------------------------
@@ -382,6 +439,73 @@ export function syncWikilinks(
382
439
  // Unresolved wikilinks — pending resolution when target notes are created
383
440
  // ---------------------------------------------------------------------------
384
441
 
442
+ /**
443
+ * Self-heal the `relationship` column onto a pre-vault#555
444
+ * `unresolved_wikilinks` table. An `ALTER TABLE ADD COLUMN` alone can't
445
+ * widen the PRIMARY KEY, which would let a structured link (non-"wikilink"
446
+ * relationship) queued against the same (source, target_path) as an
447
+ * existing pending wikilink silently collide and get dropped by `INSERT OR
448
+ * IGNORE`. So on a table that predates the column, this rebuilds it with
449
+ * the 3-column PK (source_id, target_path, relationship) and backfills
450
+ * every existing row as `relationship = 'wikilink'` (the only kind that
451
+ * could have been queued before this fix). No-op on a fresh table (created
452
+ * directly with the new schema by {@link ensureUnresolvedTable}) or one
453
+ * already migrated. `PRAGMA table_info` on a nonexistent table returns zero
454
+ * rows rather than throwing, so this is safe to call unconditionally,
455
+ * including from read paths that don't want to create the table lazily.
456
+ */
457
+ function ensureRelationshipColumn(db: Database): void {
458
+ const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
459
+ if (cols.length === 0) return; // table doesn't exist — nothing to heal
460
+ if (cols.some((c) => c.name === "relationship")) return; // already migrated
461
+
462
+ // The rebuild is a 4-statement DDL sequence (RENAME → CREATE → INSERT…
463
+ // SELECT → DROP) that MUST be atomic (wire + generalist review, vault#555
464
+ // — same class as W7's migrateToV25 must-fix). A crash between RENAME and
465
+ // CREATE would leave NO `unresolved_wikilinks` table at all (breaking every
466
+ // subsequent wikilink/structured-link write); a crash between CREATE and
467
+ // INSERT would strand every pending forward-ref row in the orphaned
468
+ // `_pre_v555` table permanently — silent data loss — because the guard
469
+ // above (`relationship` column present) flips true the moment CREATE
470
+ // commits, so a retry would skip the backfill.
471
+ const migrate = (): void => {
472
+ db.exec("ALTER TABLE unresolved_wikilinks RENAME TO unresolved_wikilinks_pre_v555");
473
+ db.exec(`
474
+ CREATE TABLE unresolved_wikilinks (
475
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
476
+ target_path TEXT NOT NULL COLLATE NOCASE,
477
+ relationship TEXT NOT NULL DEFAULT '${WIKILINK_REL}',
478
+ PRIMARY KEY (source_id, target_path, relationship)
479
+ )
480
+ `);
481
+ db.exec(`
482
+ INSERT INTO unresolved_wikilinks (source_id, target_path, relationship)
483
+ SELECT source_id, target_path, '${WIKILINK_REL}' FROM unresolved_wikilinks_pre_v555
484
+ `);
485
+ db.exec("DROP TABLE unresolved_wikilinks_pre_v555");
486
+ };
487
+
488
+ // Nesting guard: this heal runs from `ensureUnresolvedTable` /
489
+ // `resolveUnresolvedWikilinks`, which are themselves reached from
490
+ // `store.createNote`/`updateNote` — and a BATCH create/update wraps those
491
+ // in `transactionAsync` (an already-open transaction). The txn seam is
492
+ // single-level by design (see core/src/txn.ts — a nested `BEGIN IMMEDIATE`
493
+ // throws "cannot start a transaction within a transaction"), so when a
494
+ // transaction is already active the DDL is ALREADY covered by that
495
+ // transaction's atomicity and we run it directly; only when idle do we open
496
+ // our own. `db.inTransaction` is bun:sqlite's active-transaction flag; a
497
+ // backend that doesn't expose it (a DO-SQLite Store) reads `undefined`
498
+ // (falsy) and takes the `transaction()` path — which for that backend
499
+ // delegates to its native `transactionSync`. (Immaterial in practice: a
500
+ // fresh DO deployment is created with the v555 schema directly, so this
501
+ // legacy heal never fires there.)
502
+ if (db.inTransaction) {
503
+ migrate();
504
+ } else {
505
+ transaction(db, migrate);
506
+ }
507
+ }
508
+
385
509
  /**
386
510
  * Ensure the unresolved_wikilinks table exists.
387
511
  * Called lazily — only when we actually have unresolved links.
@@ -391,13 +515,18 @@ export function ensureUnresolvedTable(db: Database): void {
391
515
  CREATE TABLE IF NOT EXISTS unresolved_wikilinks (
392
516
  source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
393
517
  target_path TEXT NOT NULL COLLATE NOCASE,
394
- PRIMARY KEY (source_id, target_path)
518
+ relationship TEXT NOT NULL DEFAULT '${WIKILINK_REL}',
519
+ PRIMARY KEY (source_id, target_path, relationship)
395
520
  )
396
521
  `);
522
+ ensureRelationshipColumn(db);
397
523
  }
398
524
 
399
525
  /**
400
- * Update unresolved wikilinks for a note.
526
+ * Update unresolved wikilinks for a note. Scoped to `relationship =
527
+ * "wikilink"` rows only (vault#555) — a content re-parse must not clobber
528
+ * pending STRUCTURED-link forward-refs queued for this note via
529
+ * {@link queueUnresolvedLink} (a different relationship, same table).
401
530
  */
402
531
  function syncUnresolvedWikilinks(
403
532
  db: Database,
@@ -405,9 +534,9 @@ function syncUnresolvedWikilinks(
405
534
  unresolvedPaths: string[],
406
535
  ): void {
407
536
  if (unresolvedPaths.length === 0) {
408
- // Clean up any old unresolved entries for this note
537
+ // Clean up any old wikilink-kind unresolved entries for this note
409
538
  try {
410
- db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ?").run(noteId);
539
+ db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
411
540
  } catch {
412
541
  // Table may not exist yet — that's fine
413
542
  }
@@ -416,19 +545,22 @@ function syncUnresolvedWikilinks(
416
545
 
417
546
  ensureUnresolvedTable(db);
418
547
 
419
- // Replace all unresolved entries for this note
420
- db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ?").run(noteId);
548
+ // Replace wikilink-kind unresolved entries for this note only
549
+ db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
421
550
  const insert = db.prepare(
422
- "INSERT OR IGNORE INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)",
551
+ "INSERT OR IGNORE INTO unresolved_wikilinks (source_id, target_path, relationship) VALUES (?, ?, ?)",
423
552
  );
424
553
  for (const path of unresolvedPaths) {
425
- insert.run(noteId, path);
554
+ insert.run(noteId, path, WIKILINK_REL);
426
555
  }
427
556
  }
428
557
 
429
558
  /**
430
- * Try to resolve pending wikilinks that point to a given path.
431
- * Called when a note is created or its path changes.
559
+ * Try to resolve pending wikilinks AND pending structured-link forward-refs
560
+ * (vault#555) that point to a given path. Called when a note is created or
561
+ * its path changes. Each pending row materializes with ITS OWN relationship
562
+ * (a structured link queued via {@link queueUnresolvedLink} backfills with
563
+ * the caller's original relationship, not "wikilink").
432
564
  *
433
565
  * Returns the number of links resolved.
434
566
  */
@@ -437,13 +569,14 @@ export function resolveUnresolvedWikilinks(
437
569
  notePath: string,
438
570
  noteId: string,
439
571
  ): number {
440
- let rows: { source_id: string }[];
572
+ ensureRelationshipColumn(db);
573
+ let rows: { source_id: string; relationship: string }[];
441
574
  try {
442
575
  rows = db.prepare(`
443
- SELECT source_id FROM unresolved_wikilinks
576
+ SELECT source_id, relationship FROM unresolved_wikilinks
444
577
  WHERE target_path = ? COLLATE NOCASE
445
578
  OR ? LIKE '%/' || target_path
446
- `).all(notePath, notePath) as { source_id: string }[];
579
+ `).all(notePath, notePath) as { source_id: string; relationship: string }[];
447
580
  } catch {
448
581
  return 0; // Table doesn't exist
449
582
  }
@@ -454,15 +587,91 @@ export function resolveUnresolvedWikilinks(
454
587
  for (const row of rows) {
455
588
  if (row.source_id === noteId) continue; // Skip self-links
456
589
 
457
- // Create the wikilink
458
- linkOps.createLink(db, row.source_id, noteId, WIKILINK_REL);
590
+ const relationship = row.relationship || WIKILINK_REL;
591
+ linkOps.createLink(db, row.source_id, noteId, relationship);
459
592
  resolved++;
460
593
 
461
- // Remove the unresolved entry
594
+ // Remove the unresolved entry (this exact relationship only — a
595
+ // source may have BOTH a wikilink and a structured-link forward-ref
596
+ // pending against the same target_path).
462
597
  db.prepare(
463
- "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND (target_path = ? COLLATE NOCASE OR ? LIKE '%/' || target_path)",
464
- ).run(row.source_id, notePath, notePath);
598
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ? AND (target_path = ? COLLATE NOCASE OR ? LIKE '%/' || target_path)",
599
+ ).run(row.source_id, relationship, notePath, notePath);
465
600
  }
466
601
 
467
602
  return resolved;
468
603
  }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Structured links — same resolution + lazy forward-ref semantics as
607
+ // [[wikilinks]] (vault#555). A structured `links: [{target, relationship}]`
608
+ // entry (create-note/update-note) used to resolve by exact path only, with
609
+ // no basename fallback and no forward-ref queueing — silently dropping the
610
+ // edge whenever the equivalent `[[wikilink]]` would have resolved. These
611
+ // helpers give both the MCP tool layer (core/src/mcp.ts) and the REST
612
+ // handler layer (src/routes.ts) one shared implementation so they can't
613
+ // drift.
614
+ // ---------------------------------------------------------------------------
615
+
616
+ /**
617
+ * Resolve a structured-link `target` — an ID or a path/title — to a note
618
+ * ID. ID lookup first (structured links accept "note ID or path" per the
619
+ * tool docs; wikilinks never carry a raw ID), then the SAME path/basename
620
+ * resolution `[[wikilinks]]` use ({@link resolveWikilink}: explicit
621
+ * extension, exact path, basename).
622
+ */
623
+ export function resolveLinkTarget(db: Database, idOrPath: string): string | null {
624
+ const byId = db.prepare("SELECT id FROM notes WHERE id = ?").get(idOrPath) as { id: string } | null;
625
+ if (byId) return byId.id;
626
+ return resolveWikilink(db, idOrPath);
627
+ }
628
+
629
+ /**
630
+ * Resolve a structured-link `target` (create-note/update-note `links`) to
631
+ * its full {@link Note} — same ID-or-path/title semantics as `[[wikilinks]]`
632
+ * via {@link resolveLinkTarget}. Used for `links.remove`, where the
633
+ * bracket-cleanup step needs the target's `path`, not just its ID.
634
+ * `links.add` resolution goes through {@link resolveOrQueueLink} instead
635
+ * (it also queues a forward-ref on a miss). Single home for both the MCP
636
+ * tool layer (core/src/mcp.ts) and the REST handler layer (src/routes.ts) —
637
+ * vault#555 generalist review, was duplicated verbatim in both.
638
+ */
639
+ export function resolveStructuredLinkNote(db: Database, target: string): Note | null {
640
+ const id = resolveLinkTarget(db, target);
641
+ return id ? getNote(db, id) : null;
642
+ }
643
+
644
+ /** Queue a structured-link forward-ref for lazy resolution. */
645
+ export function queueUnresolvedLink(
646
+ db: Database,
647
+ sourceId: string,
648
+ targetPath: string,
649
+ relationship: string,
650
+ ): void {
651
+ ensureUnresolvedTable(db);
652
+ db.prepare(
653
+ "INSERT OR IGNORE INTO unresolved_wikilinks (source_id, target_path, relationship) VALUES (?, ?, ?)",
654
+ ).run(sourceId, targetPath, relationship);
655
+ }
656
+
657
+ /**
658
+ * Resolve a structured link NOW, or queue it for lazy resolution when the
659
+ * target doesn't exist yet — mirroring the wikilink forward-ref contract
660
+ * (a target created later, in this same batch or a future call, backfills
661
+ * the edge automatically via {@link resolveUnresolvedWikilinks}). Returns
662
+ * the resolved note ID, or `null` when queued. Callers MUST surface an
663
+ * `unresolved_link` warning naming the target when this returns `null` —
664
+ * the write itself never fails, but silence is never the fallback
665
+ * (vault#555 — "the API should never silently do the wrong thing").
666
+ */
667
+ export function resolveOrQueueLink(
668
+ db: Database,
669
+ sourceId: string,
670
+ target: string,
671
+ relationship: string,
672
+ ): string | null {
673
+ const resolvedId = resolveLinkTarget(db, target);
674
+ if (resolvedId) return resolvedId;
675
+ queueUnresolvedLink(db, sourceId, target, relationship);
676
+ return null;
677
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.7",
3
+ "version": "0.7.0-rc.9",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -261,4 +261,77 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
261
261
  const bRecord = await store.getTagRecord("b");
262
262
  expect(bRecord?.fields ?? null).toBeFalsy();
263
263
  });
264
+
265
+ // vault#555 fix 4 — a non-indexed field's `type` was NEVER validated
266
+ // anywhere: `mapFieldType` (the only prior type check) ran solely on
267
+ // `indexed: true` fields. `update-tag{fields:{weird:{type:"frobnicator"}}}`
268
+ // used to be silently accepted and persisted verbatim.
269
+ it("REST PUT /api/tags/:name rejects an unrecognized field type with 422 tag_field_conflict / invalid_type", async () => {
270
+ const req = new Request("http://localhost/api/tags/widget", {
271
+ method: "PUT",
272
+ body: JSON.stringify({ fields: { weird: { type: "frobnicator" } } }),
273
+ });
274
+ const res = await handleTags(req, store, "/widget");
275
+ expect(res.status).toBe(422);
276
+ const body: any = await res.json();
277
+ expect(body.error_type).toBe("tag_field_conflict");
278
+ expect(body.violations).toHaveLength(1);
279
+ expect(body.violations[0].field).toBe("weird");
280
+ expect(body.violations[0].reason).toBe("invalid_type");
281
+ expect(body.violations[0].message).toContain("frobnicator");
282
+ expect(body.violations[0].message).toContain("string");
283
+
284
+ // Nothing persisted.
285
+ const record = await store.getTagRecord("widget");
286
+ expect(record?.fields ?? null).toBeFalsy();
287
+ });
288
+
289
+ // vault#555 fix 5 — invalid_field_default used to be FAIL-FAST on REST
290
+ // (a single-violation 400, silently dropping every OTHER bad field in the
291
+ // same call). Now bundled with invalid_type (and any cross-tag
292
+ // violations) into ONE 422 report — every invalid field at once.
293
+ it("REST PUT /api/tags/:name reports a bad default AND an unrecognized type together, not first-only", async () => {
294
+ const req = new Request("http://localhost/api/tags/widget", {
295
+ method: "PUT",
296
+ body: JSON.stringify({
297
+ fields: {
298
+ weird: { type: "frobnicator" },
299
+ bad_default: { type: "string", enum: ["a", "b"], default: "zzz" },
300
+ },
301
+ }),
302
+ });
303
+ const res = await handleTags(req, store, "/widget");
304
+ expect(res.status).toBe(422);
305
+ const body: any = await res.json();
306
+ expect(body.error_type).toBe("tag_field_conflict");
307
+ expect(body.violations).toHaveLength(2);
308
+ const byField = new Map(body.violations.map((v: any) => [v.field, v.reason]));
309
+ expect(byField.get("weird")).toBe("invalid_type");
310
+ expect(byField.get("bad_default")).toBe("invalid_default");
311
+ expect(body.message).toContain("no changes were applied");
312
+
313
+ // Nothing persisted.
314
+ const record = await store.getTagRecord("widget");
315
+ expect(record?.fields ?? null).toBeFalsy();
316
+ });
317
+
318
+ // Positive control — every one of the six recognized types (indexable or
319
+ // not) is accepted without complaint.
320
+ it("REST PUT /api/tags/:name accepts every recognized field type", async () => {
321
+ const req = new Request("http://localhost/api/tags/widget", {
322
+ method: "PUT",
323
+ body: JSON.stringify({
324
+ fields: {
325
+ a: { type: "string" },
326
+ b: { type: "number" },
327
+ c: { type: "integer" },
328
+ d: { type: "boolean" },
329
+ e: { type: "array" },
330
+ f: { type: "object" },
331
+ },
332
+ }),
333
+ });
334
+ const res = await handleTags(req, store, "/widget");
335
+ expect(res.status).toBe(200);
336
+ });
264
337
  });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * MCP JSON-RPC error mapping — no double-wrap (vault#555 fix 6).
3
+ *
4
+ * Verified at code: the catch block in `handleMcp` read `err.message` and
5
+ * fed it into a fresh `new McpError(...)`. The MCP SDK's `McpError`
6
+ * constructor bakes `"MCP error <code>: "` into `.message` ITSELF (not just
7
+ * `.toString()`) — confirmed directly against the SDK below. So an already-
8
+ * formed `McpError` reaching that catch block (or a plain error whose
9
+ * `.message` happens to already carry that prefix — e.g. forwarded verbatim
10
+ * from another MCP-speaking service) got double-prefixed:
11
+ * `"MCP error -32602: MCP error -32602: ..."`. The structured
12
+ * `data.error_type` was always correct — only the human-readable `message`
13
+ * string could double.
14
+ *
15
+ * `handleMcp` (the underlying dispatcher `handleScopedMcp` wraps) is
16
+ * exported specifically so a test can inject a synthetic failing tool and
17
+ * exercise the exact catch-block path without needing a real domain error
18
+ * class that happens to throw an `McpError` naturally (none of core's tools
19
+ * do — `McpError` is only ever constructed inside `mcp-http.ts` itself).
20
+ */
21
+ import { describe, test, expect } from "bun:test";
22
+ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
23
+ import { handleMcp, mcpDomainError } from "./mcp-http.ts";
24
+ import type { McpToolDef } from "../core/src/mcp.ts";
25
+ import type { AuthResult } from "./auth.ts";
26
+
27
+ function toolsWith(execute: (params: Record<string, unknown>) => unknown): () => McpToolDef[] {
28
+ return () => [
29
+ {
30
+ name: "boom",
31
+ description: "throws whatever `execute` throws",
32
+ inputSchema: { type: "object", properties: {} },
33
+ requiredVerb: "read",
34
+ execute,
35
+ },
36
+ ];
37
+ }
38
+
39
+ function fullAuth(): AuthResult {
40
+ return {
41
+ permission: "full",
42
+ scopes: ["vault:v:read", "vault:v:write", "vault:v:admin"],
43
+ legacyDerived: false,
44
+ scoped_tags: null,
45
+ vault_name: null,
46
+ caller_jti: null,
47
+ actor: "test-user",
48
+ via: "api",
49
+ };
50
+ }
51
+
52
+ async function callBoom(execute: (params: Record<string, unknown>) => unknown): Promise<any> {
53
+ const req = new Request("http://localhost:1940/vault/v/mcp", {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
56
+ body: JSON.stringify({
57
+ jsonrpc: "2.0",
58
+ id: 1,
59
+ method: "tools/call",
60
+ params: { name: "boom", arguments: {} },
61
+ }),
62
+ });
63
+ const res = await handleMcp(req, toolsWith(execute), "test-server", "v", fullAuth(), "");
64
+ return res.json();
65
+ }
66
+
67
+ describe("MCP SDK McpError — confirms the mechanics this fix depends on", () => {
68
+ test("the SDK bakes the JSON-RPC prefix into .message itself, not just .toString()", () => {
69
+ const err = new McpError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
70
+ expect(err.message).toBe("MCP error -32602: bad stuff happened");
71
+ // Naive re-wrap (the pre-fix behavior) doubles it — this is the exact
72
+ // bug, reproduced directly against the SDK class.
73
+ const naiveRewrap = new McpError(ErrorCode.InvalidParams, err.message, { error_type: "invalid_query" });
74
+ expect(naiveRewrap.message).toBe("MCP error -32602: MCP error -32602: bad stuff happened");
75
+ });
76
+ });
77
+
78
+ describe("mcpDomainError — single source of truth for the JSON-RPC mapping (vault#555 fix 6)", () => {
79
+ test("a plain message gets the error_type token prepended (optional polish)", () => {
80
+ const err = mcpDomainError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
81
+ expect(err.message).toBe("MCP error -32602: [invalid_query] bad stuff happened");
82
+ expect(err.data).toEqual({ error_type: "invalid_query" });
83
+ });
84
+
85
+ test("a message that ALREADY carries the JSON-RPC prefix is not doubled", () => {
86
+ const err = mcpDomainError(ErrorCode.InvalidParams, "MCP error -32602: bad stuff happened", {
87
+ error_type: "invalid_query",
88
+ });
89
+ expect(err.message).toBe("MCP error -32602: [invalid_query] bad stuff happened");
90
+ expect(err.message).not.toContain("MCP error -32602: MCP error");
91
+ });
92
+
93
+ test("no error_type in data — message passes through without a bracketed token", () => {
94
+ const err = mcpDomainError(ErrorCode.InternalError, "something broke", {});
95
+ expect(err.message).toBe("MCP error -32603: something broke");
96
+ });
97
+ });
98
+
99
+ describe("handleMcp JSON-RPC error mapping — end to end (vault#555 fix 6)", () => {
100
+ test("a tool throwing an ALREADY-formed McpError passes through unchanged — no double prefix, data preserved verbatim", async () => {
101
+ const inner = new McpError(ErrorCode.InvalidRequest, "conflict: note was modified", {
102
+ error_type: "conflict",
103
+ note_id: "abc123",
104
+ hint: "re-read and retry",
105
+ });
106
+ const body = await callBoom(() => {
107
+ throw inner;
108
+ });
109
+ expect(body.error.code).toBe(ErrorCode.InvalidRequest);
110
+ // Single prefix — NOT "MCP error -32600: MCP error -32600: ...".
111
+ expect(body.error.message).toBe("MCP error -32600: conflict: note was modified");
112
+ expect((body.error.message.match(/MCP error/g) ?? []).length).toBe(1);
113
+ // data.error_type fidelity preserved exactly — nothing re-derived.
114
+ expect(body.error.data).toEqual({
115
+ error_type: "conflict",
116
+ note_id: "abc123",
117
+ hint: "re-read and retry",
118
+ });
119
+ });
120
+
121
+ test("a plain domain error (never wrapped) is mapped once, with the error_type token in the human message", async () => {
122
+ const body = await callBoom(() => {
123
+ throw Object.assign(new Error("something went wrong"), { error_type: "invalid_query", field: "limit" });
124
+ });
125
+ expect(body.error.message).toBe("MCP error -32602: [invalid_query] something went wrong");
126
+ expect((body.error.message.match(/MCP error/g) ?? []).length).toBe(1);
127
+ expect(body.error.data.error_type).toBe("invalid_query");
128
+ expect(body.error.data.field).toBe("limit");
129
+ });
130
+
131
+ test("a truly unknown error (no error_type anywhere) falls through to the unstructured isError text, not a thrown McpError", async () => {
132
+ const body = await callBoom(() => {
133
+ throw new Error("plain unstructured failure");
134
+ });
135
+ // No JSON-RPC `error` — the tool result carries isError: true instead.
136
+ expect(body.error).toBeUndefined();
137
+ expect(body.result.isError).toBe(true);
138
+ expect(body.result.content[0].text).toContain("plain unstructured failure");
139
+ });
140
+ });