@openparachute/vault 0.7.3-rc.10 → 0.7.3-rc.11

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/routes.ts CHANGED
@@ -143,7 +143,7 @@ import {
143
143
  type ExpandMode,
144
144
  } from "../core/src/expand.ts";
145
145
  import { join, extname, normalize } from "path";
146
- import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
146
+ import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
147
147
  import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
148
148
  import { shouldAutoTranscribe } from "./auto-transcribe.ts";
149
149
  // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
@@ -4453,6 +4453,56 @@ export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 12
4453
4453
  const BLOCKED_EXTENSIONS = BLOCKED_ATTACHMENT_EXTENSIONS;
4454
4454
  const MIME_TYPES = ATTACHMENT_MIME_TYPES;
4455
4455
 
4456
+ /**
4457
+ * Parse a single-range `Range: bytes=a-b` header (RFC 7233 §2.1 — single
4458
+ * range only, attachments-for-agents design D9, the REST twin of MCP's
4459
+ * `content_offset`). Returns `null` — this function's own contract is
4460
+ * "serve the full response, unranged" — for a missing header, a
4461
+ * MALFORMED value, an unrecognized unit, a multi-range list
4462
+ * (`bytes=0-10,20-30` — ignored, not an error, per D9), or a range this
4463
+ * file can't satisfy (a `start` past EOF). `start`/`end` are both
4464
+ * INCLUSIVE byte offsets; `end` is clamped to `total - 1` when the
4465
+ * request left it open (`bytes=500-`) or asked past EOF.
4466
+ *
4467
+ * IMPORTANT — this `null` contract is NOT the last word for an
4468
+ * unsatisfiable (syntactically-valid but out-of-bounds) range on a real
4469
+ * `Bun.serve()` deployment. Live-verified against an actual socket (not
4470
+ * the in-process `handleStorage()` call the test suite uses): when the
4471
+ * response body is a `Bun.file()` — which `handleStorage`'s full-response
4472
+ * branch below always hands back — Bun's OWN runtime transparently
4473
+ * reinterprets the incoming request's `Range` header a second time and,
4474
+ * for an out-of-bounds range, overrides our 200 with a native
4475
+ * **416 Range Not Satisfiable**, regardless of what this function or
4476
+ * `handleStorage` returned. That's RFC 7233-correct and is being KEPT,
4477
+ * not fought — so in practice, `null` from an out-of-bounds `start`
4478
+ * still results in a 200 from `handleStorage`'s own logic, but the byte
4479
+ * that actually reaches a real client for that specific case is a 416
4480
+ * courtesy of Bun itself. MALFORMED and multi-range headers are NOT
4481
+ * range-shaped at all, so Bun's native layer doesn't touch them — those
4482
+ * two cases genuinely serve the full 200, in-process harness and real
4483
+ * socket alike.
4484
+ */
4485
+ export function parseByteRangeHeader(header: string | null, total: number): { start: number; end: number } | null {
4486
+ if (!header || total <= 0) return null;
4487
+ const match = header.match(/^bytes=(\d*)-(\d*)$/);
4488
+ if (!match) return null; // malformed, unrecognized unit, or a multi-range list
4489
+ const [, startRaw, endRaw] = match;
4490
+ if (startRaw === "" && endRaw === "") return null;
4491
+
4492
+ if (startRaw === "") {
4493
+ // Suffix range: last N bytes (`bytes=-500`).
4494
+ const suffixLength = Number(endRaw);
4495
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
4496
+ return { start: Math.max(0, total - suffixLength), end: total - 1 };
4497
+ }
4498
+
4499
+ const start = Number(startRaw);
4500
+ if (!Number.isSafeInteger(start) || start < 0 || start >= total) return null;
4501
+ const end = endRaw === "" ? total - 1 : Math.min(Number(endRaw), total - 1);
4502
+ if (!Number.isSafeInteger(end) || end < start) return null;
4503
+ return { start, end };
4504
+ }
4505
+
4456
4506
  export async function handleStorage(
4457
4507
  req: Request,
4458
4508
  path: string,
@@ -4615,12 +4665,38 @@ export async function handleStorage(
4615
4665
  const stat = statSync(filePath);
4616
4666
  const ext = extname(filePath).toLowerCase();
4617
4667
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
4618
- const fileBuffer = readFileSync(filePath);
4668
+ const total = stat.size;
4669
+
4670
+ // vault attachments-for-agents design (D9) — the REST twin of MCP's
4671
+ // `content_offset`. `Bun.file(filePath)` resolves lazily: `.slice()`
4672
+ // creates a bounded view and only the requested bytes are actually read
4673
+ // on `.arrayBuffer()`/streaming, replacing the prior whole-file
4674
+ // `readFileSync` (a standing memory smell on a large attachment, e.g. a
4675
+ // 90 MB video) on BOTH the ranged and full-file paths below.
4676
+ const bunFile = Bun.file(filePath);
4677
+ const range = parseByteRangeHeader(req.headers.get("range"), total);
4678
+
4679
+ if (range) {
4680
+ const { start, end } = range; // inclusive
4681
+ return new Response(bunFile.slice(start, end + 1), {
4682
+ status: 206,
4683
+ headers: {
4684
+ "Content-Type": contentType,
4685
+ "Content-Length": String(end - start + 1),
4686
+ "Content-Range": `bytes ${start}-${end}/${total}`,
4687
+ "Accept-Ranges": "bytes",
4688
+ // Defense-in-depth: never let a browser MIME-sniff a stored asset
4689
+ // into an active type — see the full-response branch below.
4690
+ "X-Content-Type-Options": "nosniff",
4691
+ },
4692
+ });
4693
+ }
4619
4694
 
4620
- return new Response(fileBuffer, {
4695
+ return new Response(bunFile, {
4621
4696
  headers: {
4622
4697
  "Content-Type": contentType,
4623
- "Content-Length": String(stat.size),
4698
+ "Content-Length": String(total),
4699
+ "Accept-Ranges": "bytes",
4624
4700
  // Defense-in-depth: never let a browser MIME-sniff a stored asset into
4625
4701
  // an active type (e.g. an octet-stream body sniffed as text/html).
4626
4702
  // Combined with the upload blocklist (no .svg/.html) this closes the
package/src/server.ts CHANGED
@@ -21,6 +21,7 @@ import { migrateVaultKeys } from "./token-store.ts";
21
21
  import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
22
22
  import { getVaultStore, getVaultNameForStore, getSharedEmbeddingProvider } from "./vault-store.ts";
23
23
  import { EmbeddingWorker, registerEmbeddingHook } from "./embedding-worker.ts";
24
+ import { startAttachmentTicketSweep, stopAttachmentTicketSweep } from "./attachment-tickets.ts";
24
25
  import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
25
26
  import { defaultHookRegistry } from "../core/src/hooks.ts";
26
27
  import { registerTriggers } from "./triggers.ts";
@@ -237,6 +238,11 @@ const embeddingWorker = new EmbeddingWorker({
237
238
  registerEmbeddingHook(defaultHookRegistry, embeddingWorker, (store) => getVaultNameForStore(store as never));
238
239
  embeddingWorker.start();
239
240
 
241
+ // Attachment-ticket sweep (vault#612) — drops expired-unspent tickets from
242
+ // the in-process store so an abandoned mint (an agent that never curls)
243
+ // doesn't sit in memory forever. See src/attachment-tickets.ts.
244
+ startAttachmentTicketSweep();
245
+
240
246
  if (process.env.VAULT_AUTH_TOKEN?.trim()) {
241
247
  console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
242
248
  }
@@ -619,6 +625,7 @@ async function shutdown(signal: string): Promise<void> {
619
625
  // Then drain hooks + stop the transcription/embedding workers in
620
626
  // parallel.
621
627
  embeddingWorker.stop();
628
+ stopAttachmentTicketSweep();
622
629
  await Promise.all([
623
630
  defaultHookRegistry.drain(),
624
631
  transcriptionWorker?.stop() ?? Promise.resolve(),
@@ -30,7 +30,7 @@ const testDir = join(
30
30
  process.env.PARACHUTE_HOME = testDir;
31
31
  process.env.ASSETS_DIR = join(testDir, "assets");
32
32
 
33
- const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES } = await import("./routes.ts");
33
+ const { handleStorage, MAX_UPLOAD_BYTES, MAX_REQUEST_BODY_BYTES, parseByteRangeHeader } = await import("./routes.ts");
34
34
  const { expandTokenTagScope } = await import("./tag-scope.ts");
35
35
 
36
36
  // The upload-allowlist tests never touch the store (POST /upload writes to
@@ -564,3 +564,202 @@ describe("storage POST parity (finding D)", () => {
564
564
  expect(res.status).toBe(404);
565
565
  });
566
566
  });
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // REST Range (206) — vault attachments-for-agents design D9, the REST twin
570
+ // of MCP's content_offset/content_length.
571
+ // ---------------------------------------------------------------------------
572
+
573
+ describe("parseByteRangeHeader", () => {
574
+ test("no header → null (full response)", () => {
575
+ expect(parseByteRangeHeader(null, 100)).toBeNull();
576
+ });
577
+
578
+ test("bytes=0-99 on a 100-byte file → the whole file as one satisfiable range", () => {
579
+ expect(parseByteRangeHeader("bytes=0-99", 100)).toEqual({ start: 0, end: 99 });
580
+ });
581
+
582
+ test("bytes=10-19 → an inclusive mid-file window", () => {
583
+ expect(parseByteRangeHeader("bytes=10-19", 100)).toEqual({ start: 10, end: 19 });
584
+ });
585
+
586
+ test("bytes=90- (open-ended) → reads to EOF", () => {
587
+ expect(parseByteRangeHeader("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
588
+ });
589
+
590
+ test("bytes=-10 (suffix range) → the last 10 bytes", () => {
591
+ expect(parseByteRangeHeader("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
592
+ });
593
+
594
+ test("bytes=-1000 (suffix longer than the file) → clamps to the whole file", () => {
595
+ expect(parseByteRangeHeader("bytes=-1000", 100)).toEqual({ start: 0, end: 99 });
596
+ });
597
+
598
+ test("bytes=50-1000 (end past EOF) → clamps end to the last byte", () => {
599
+ expect(parseByteRangeHeader("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
600
+ });
601
+
602
+ test("start past EOF → null (this function's own contract; a real Bun.serve() socket overrides with a native 416 — see doc comment)", () => {
603
+ expect(parseByteRangeHeader("bytes=100-200", 100)).toBeNull();
604
+ expect(parseByteRangeHeader("bytes=1000-", 100)).toBeNull();
605
+ });
606
+
607
+ test("start > end → null (malformed)", () => {
608
+ expect(parseByteRangeHeader("bytes=50-10", 100)).toBeNull();
609
+ });
610
+
611
+ test("bytes=- (both empty) → null", () => {
612
+ expect(parseByteRangeHeader("bytes=-", 100)).toBeNull();
613
+ });
614
+
615
+ test("a multi-range list (bytes=0-10,20-30) → null (ignored per D9, not an error)", () => {
616
+ expect(parseByteRangeHeader("bytes=0-10,20-30", 100)).toBeNull();
617
+ });
618
+
619
+ test("an unrecognized unit → null", () => {
620
+ expect(parseByteRangeHeader("items=0-10", 100)).toBeNull();
621
+ });
622
+
623
+ test("garbage value → null, not a throw", () => {
624
+ expect(parseByteRangeHeader("bytes=abc-def", 100)).toBeNull();
625
+ expect(parseByteRangeHeader("nonsense", 100)).toBeNull();
626
+ });
627
+
628
+ test("a zero-byte file → null regardless of header (nothing to range over)", () => {
629
+ expect(parseByteRangeHeader("bytes=0-0", 0)).toBeNull();
630
+ });
631
+ });
632
+
633
+ describe("storage GET Range support (vault attachments-for-agents design D9)", () => {
634
+ const VAULT = "range-vault";
635
+ // 26 bytes, byte-identical to its own index — content[i] === i (0x00..0x19)
636
+ // — so any slice's bytes can be asserted exactly by index, not just length.
637
+ const CONTENT = Buffer.from(Array.from({ length: 26 }, (_, i) => i));
638
+
639
+ async function setup(): Promise<{ store: SqliteStore; assets: string; relPath: string }> {
640
+ const store = freshStore();
641
+ const assets = join(testDir, "assets", VAULT, "data");
642
+ mkdirSync(join(assets, "2026-05-28"), { recursive: true });
643
+ process.env.ASSETS_DIR = assets;
644
+
645
+ const relPath = "2026-05-28/ranged.bin";
646
+ writeFileSync(join(assets, relPath), CONTENT);
647
+ const note = await store.createNote("range note", { tags: ["misc"] });
648
+ await store.addAttachment(note.id, relPath, "application/octet-stream");
649
+
650
+ return { store, assets, relPath };
651
+ }
652
+
653
+ function getReq(reqPath: string, range?: string): Request {
654
+ const headers: Record<string, string> = {};
655
+ if (range !== undefined) headers.range = range;
656
+ return new Request(`http://localhost:1940/storage/${reqPath}`, { method: "GET", headers });
657
+ }
658
+
659
+ test("no Range header → 200, full body, Accept-Ranges advertised", async () => {
660
+ const { store, relPath } = await setup();
661
+ const res = await handleStorage(getReq(relPath), `/${relPath}`, VAULT, store);
662
+ expect(res.status).toBe(200);
663
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
664
+ expect(res.headers.get("Content-Length")).toBe("26");
665
+ expect(res.headers.has("Content-Range")).toBe(false);
666
+ const body = Buffer.from(await res.arrayBuffer());
667
+ expect(body.equals(CONTENT)).toBe(true);
668
+ });
669
+
670
+ test("Range: bytes=0-9 → 206, first 10 bytes, correct Content-Range", async () => {
671
+ const { store, relPath } = await setup();
672
+ const res = await handleStorage(getReq(relPath, "bytes=0-9"), `/${relPath}`, VAULT, store);
673
+ expect(res.status).toBe(206);
674
+ expect(res.headers.get("Content-Range")).toBe("bytes 0-9/26");
675
+ expect(res.headers.get("Content-Length")).toBe("10");
676
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
677
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
678
+ const body = Buffer.from(await res.arrayBuffer());
679
+ expect(body.equals(CONTENT.subarray(0, 10))).toBe(true);
680
+ });
681
+
682
+ test("Range: bytes=16-25 (to EOF) → 206, correct tail bytes", async () => {
683
+ const { store, relPath } = await setup();
684
+ const res = await handleStorage(getReq(relPath, "bytes=16-25"), `/${relPath}`, VAULT, store);
685
+ expect(res.status).toBe(206);
686
+ expect(res.headers.get("Content-Range")).toBe("bytes 16-25/26");
687
+ const body = Buffer.from(await res.arrayBuffer());
688
+ expect(body.equals(CONTENT.subarray(16, 26))).toBe(true);
689
+ });
690
+
691
+ test("Range: bytes=-5 (suffix) → 206, last 5 bytes", async () => {
692
+ const { store, relPath } = await setup();
693
+ const res = await handleStorage(getReq(relPath, "bytes=-5"), `/${relPath}`, VAULT, store);
694
+ expect(res.status).toBe(206);
695
+ expect(res.headers.get("Content-Range")).toBe("bytes 21-25/26");
696
+ const body = Buffer.from(await res.arrayBuffer());
697
+ expect(body.equals(CONTENT.subarray(21, 26))).toBe(true);
698
+ });
699
+
700
+ test("paging the whole file via sequential ranges reassembles byte-identical content", async () => {
701
+ const { store, relPath } = await setup();
702
+ const chunkSize = 7; // doesn't divide 26 evenly — exercises a short final chunk
703
+ const chunks: Buffer[] = [];
704
+ for (let start = 0; start < 26; start += chunkSize) {
705
+ const end = Math.min(start + chunkSize - 1, 25);
706
+ const res = await handleStorage(getReq(relPath, `bytes=${start}-${end}`), `/${relPath}`, VAULT, store);
707
+ expect(res.status).toBe(206);
708
+ chunks.push(Buffer.from(await res.arrayBuffer()));
709
+ }
710
+ expect(Buffer.concat(chunks).equals(CONTENT)).toBe(true);
711
+ });
712
+
713
+ test("a malformed Range header → 200 full response, not an error (D9: ignore, don't fail)", async () => {
714
+ const { store, relPath } = await setup();
715
+ const res = await handleStorage(getReq(relPath, "not-a-range"), `/${relPath}`, VAULT, store);
716
+ expect(res.status).toBe(200);
717
+ expect(res.headers.has("Content-Range")).toBe(false);
718
+ const body = Buffer.from(await res.arrayBuffer());
719
+ expect(body.equals(CONTENT)).toBe(true);
720
+ });
721
+
722
+ test("a multi-range Range header → 200 full response (multipart ranges unsupported, ignored per D9)", async () => {
723
+ const { store, relPath } = await setup();
724
+ const res = await handleStorage(getReq(relPath, "bytes=0-5,10-15"), `/${relPath}`, VAULT, store);
725
+ expect(res.status).toBe(200);
726
+ const body = Buffer.from(await res.arrayBuffer());
727
+ expect(body.equals(CONTENT)).toBe(true);
728
+ });
729
+
730
+ test("an unsatisfiable Range (start past EOF) → handleStorage's OWN logic returns 200 (parseByteRangeHeader's null contract)", async () => {
731
+ // IMPORTANT — this is NOT the whole live story. `handleStorage` is
732
+ // called here directly, in-process, so this only exercises OUR
733
+ // fallback logic (parseByteRangeHeader → null → the unranged 200
734
+ // branch). Live-verified against a real `Bun.serve()` socket (not
735
+ // this harness): Bun's own runtime independently reinterprets the
736
+ // incoming `Range` header on a `Bun.file()`-backed response body and
737
+ // overrides an out-of-bounds range with a native
738
+ // **416 Range Not Satisfiable** — RFC 7233-correct, and kept, not
739
+ // fought. This in-process harness has no socket for Bun's native
740
+ // layer to intercept, so it can only ever observe the 200 our own
741
+ // code returns — see `parseByteRangeHeader`'s doc comment
742
+ // (`src/routes.ts`) for the full explanation.
743
+ const { store, relPath } = await setup();
744
+ const res = await handleStorage(getReq(relPath, "bytes=1000-2000"), `/${relPath}`, VAULT, store);
745
+ expect(res.status).toBe(200);
746
+ const body = Buffer.from(await res.arrayBuffer());
747
+ expect(body.equals(CONTENT)).toBe(true);
748
+ });
749
+
750
+ test("Range is still honored for a tag-scoped in-scope request (206)", async () => {
751
+ const { store, relPath } = await setup();
752
+ const ctx = await tagScopeCtx(store, ["misc"]);
753
+ const res = await handleStorage(getReq(relPath, "bytes=0-3"), `/${relPath}`, VAULT, store, ctx);
754
+ expect(res.status).toBe(206);
755
+ const body = Buffer.from(await res.arrayBuffer());
756
+ expect(body.equals(CONTENT.subarray(0, 4))).toBe(true);
757
+ });
758
+
759
+ test("Range on an out-of-scope attachment still 404s (same confinement guard, byte-identical to the non-ranged path)", async () => {
760
+ const { store, relPath } = await setup();
761
+ const ctx = await tagScopeCtx(store, ["totally-unrelated-tag"]);
762
+ const res = await handleStorage(getReq(relPath, "bytes=0-3"), `/${relPath}`, VAULT, store, ctx);
763
+ expect(res.status).toBe(404);
764
+ });
765
+ });
package/src/vault.test.ts CHANGED
@@ -6625,12 +6625,15 @@ describe("stateless MCP transport", async () => {
6625
6625
  expect(toolNames).not.toContain("merge-tags");
6626
6626
  // Admin tools (vault#376) are hidden too
6627
6627
  expect(toolNames).not.toContain("manage-token");
6628
- // request-attachment-download is read-tier (upload is write-tier).
6628
+ // request-attachment-download and read-attachment are read-tier
6629
+ // (upload is write-tier).
6629
6630
  expect(toolNames).toContain("request-attachment-download");
6630
6631
  expect(toolNames).not.toContain("request-attachment-upload");
6631
- // Read tier is exactly 6 tools (doctor added by the re-tier;
6632
- // request-attachment-download added by the attachment-tickets design).
6633
- expect(toolNames.length).toBe(6);
6632
+ expect(toolNames).toContain("read-attachment");
6633
+ // Read tier is exactly 7 tools (doctor added by the re-tier;
6634
+ // request-attachment-download by the attachment-tickets design;
6635
+ // read-attachment by Wave 2).
6636
+ expect(toolNames.length).toBe(7);
6634
6637
 
6635
6638
  closeAllStores();
6636
6639
  });
@@ -6911,15 +6914,23 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6911
6914
  return names;
6912
6915
  }
6913
6916
 
6914
- test("vault:read sees exactly the 6 read tools (doctor moved admin → read; request-attachment-download is read-tier)", async () => {
6917
+ test("vault:read sees exactly the 7 read tools (doctor moved admin → read; read-attachment and request-attachment-download are read-tier)", async () => {
6915
6918
  const names = await listToolNames(["vault:read"]);
6916
6919
  expect(new Set(names)).toEqual(
6917
- new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor", "request-attachment-download"]),
6920
+ new Set([
6921
+ "query-notes",
6922
+ "list-tags",
6923
+ "find-path",
6924
+ "vault-info",
6925
+ "doctor",
6926
+ "request-attachment-download",
6927
+ "read-attachment",
6928
+ ]),
6918
6929
  );
6919
- expect(names.length).toBe(6);
6930
+ expect(names.length).toBe(7);
6920
6931
  });
6921
6932
 
6922
- test("vault:read + vault:write sees the 10 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
6933
+ test("vault:read + vault:write sees the 11 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
6923
6934
  const names = await listToolNames(["vault:read", "vault:write"]);
6924
6935
  expect(new Set(names)).toEqual(
6925
6936
  new Set([
@@ -6933,9 +6944,10 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6933
6944
  "delete-note",
6934
6945
  "request-attachment-upload",
6935
6946
  "request-attachment-download",
6947
+ "read-attachment",
6936
6948
  ]),
6937
6949
  );
6938
- expect(names.length).toBe(10);
6950
+ expect(names.length).toBe(11);
6939
6951
  expect(names).not.toContain("manage-token");
6940
6952
  // Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
6941
6953
  // admin-tier — structure/taxonomy curation, not content authorship.
@@ -6947,7 +6959,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6947
6959
  expect(names).toContain("delete-note");
6948
6960
  });
6949
6961
 
6950
- test("vault:admin sees all 16 tools including manage-token + prune-schema + the tag-schema tools + both attachment-ticket tools", async () => {
6962
+ test("vault:admin sees all 17 tools including manage-token + prune-schema + the tag-schema tools + all three attachment tools", async () => {
6951
6963
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
6952
6964
  expect(names).toContain("manage-token");
6953
6965
  expect(names).toContain("prune-schema");
@@ -6958,10 +6970,11 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6958
6970
  expect(names).toContain("merge-tags");
6959
6971
  expect(names).toContain("request-attachment-upload");
6960
6972
  expect(names).toContain("request-attachment-download");
6961
- expect(names.length).toBe(16);
6973
+ expect(names).toContain("read-attachment");
6974
+ expect(names.length).toBe(17);
6962
6975
  });
6963
6976
 
6964
- test("legacy-derived full token sees all 16 tools (back-compat)", async () => {
6977
+ test("legacy-derived full token sees all 17 tools (back-compat)", async () => {
6965
6978
  const { handleScopedMcp } = await import("./mcp-http.ts");
6966
6979
  const { writeVaultConfig } = await import("./config.ts");
6967
6980
  const { closeAllStores } = await import("./vault-store.ts");
@@ -6994,7 +7007,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6994
7007
  } as any);
6995
7008
  const body = await res.json() as any;
6996
7009
  const names: string[] = body.result.tools.map((t: any) => t.name);
6997
- expect(names.length).toBe(16);
7010
+ expect(names.length).toBe(17);
6998
7011
  expect(names).toContain("manage-token");
6999
7012
  expect(names).toContain("prune-schema");
7000
7013
  expect(names).toContain("doctor");