@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.
- package/core/src/core.test.ts +895 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +439 -49
- package/core/src/notes.ts +39 -4
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +230 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-tools.ts +36 -3
- package/src/routes.ts +363 -39
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +745 -4
package/core/src/wikilinks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
431
|
-
* Called when a note is created or
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
linkOps.createLink(db, row.source_id, noteId,
|
|
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
|
@@ -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
|
+
});
|