@openparachute/vault 0.2.4 → 0.3.0

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.
Files changed (102) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/hooks.ts +111 -3
  7. package/core/src/indexed-fields.test.ts +285 -0
  8. package/core/src/indexed-fields.ts +238 -0
  9. package/core/src/mcp.ts +127 -6
  10. package/core/src/notes.ts +153 -11
  11. package/core/src/query-operators.ts +174 -0
  12. package/core/src/schema.ts +69 -2
  13. package/core/src/store.ts +95 -1
  14. package/core/src/tag-schemas.ts +5 -0
  15. package/core/src/types.ts +28 -1
  16. package/docs/HTTP_API.md +105 -1
  17. package/docs/auth-model.md +340 -0
  18. package/package/package.json +32 -0
  19. package/package.json +2 -2
  20. package/src/auth.test.ts +83 -114
  21. package/src/auth.ts +68 -6
  22. package/src/backup-launchd.ts +1 -1
  23. package/src/backup.test.ts +1 -1
  24. package/src/backup.ts +18 -17
  25. package/src/bind.test.ts +28 -0
  26. package/src/bind.ts +19 -0
  27. package/src/cli.ts +228 -133
  28. package/src/config-triggers.test.ts +49 -0
  29. package/src/config.test.ts +317 -2
  30. package/src/config.ts +420 -40
  31. package/src/context.test.ts +136 -0
  32. package/src/context.ts +115 -0
  33. package/src/daemon.ts +17 -16
  34. package/src/doctor.test.ts +9 -7
  35. package/src/launchd.test.ts +1 -1
  36. package/src/launchd.ts +6 -6
  37. package/src/mcp-http.ts +75 -21
  38. package/src/mcp-install.test.ts +125 -0
  39. package/src/mcp-install.ts +60 -0
  40. package/src/mcp-tools.ts +34 -96
  41. package/src/module-config.ts +109 -0
  42. package/src/oauth.test.ts +345 -57
  43. package/src/oauth.ts +155 -35
  44. package/src/published.test.ts +2 -2
  45. package/src/routes.ts +209 -33
  46. package/src/routing.test.ts +817 -300
  47. package/src/routing.ts +204 -202
  48. package/src/scopes.test.ts +294 -0
  49. package/src/scopes.ts +253 -0
  50. package/src/scribe-env.test.ts +49 -0
  51. package/src/scribe-env.ts +33 -0
  52. package/src/server.ts +73 -9
  53. package/src/services-manifest.test.ts +140 -0
  54. package/src/services-manifest.ts +99 -0
  55. package/src/systemd.ts +3 -3
  56. package/src/token-store.ts +42 -9
  57. package/src/transcription-worker.test.ts +864 -0
  58. package/src/transcription-worker.ts +501 -0
  59. package/src/triggers.test.ts +191 -1
  60. package/src/triggers.ts +17 -2
  61. package/src/vault.test.ts +693 -77
  62. package/src/version.test.ts +1 -1
  63. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  64. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  65. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  66. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  67. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  68. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  69. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  70. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  71. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  72. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  73. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  75. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  76. package/religions-abrahamic-filter.png +0 -0
  77. package/religions-buddhism-v2.png +0 -0
  78. package/religions-buddhism.png +0 -0
  79. package/religions-final.png +0 -0
  80. package/religions-v1.png +0 -0
  81. package/religions-v2.png +0 -0
  82. package/religions-zen.png +0 -0
  83. package/web/README.md +0 -73
  84. package/web/bun.lock +0 -827
  85. package/web/eslint.config.js +0 -23
  86. package/web/index.html +0 -15
  87. package/web/package.json +0 -36
  88. package/web/public/favicon.svg +0 -1
  89. package/web/public/icons.svg +0 -24
  90. package/web/src/App.tsx +0 -149
  91. package/web/src/Graph.tsx +0 -200
  92. package/web/src/NoteView.tsx +0 -155
  93. package/web/src/Sidebar.tsx +0 -186
  94. package/web/src/api.ts +0 -21
  95. package/web/src/index.css +0 -50
  96. package/web/src/main.tsx +0 -10
  97. package/web/src/types.ts +0 -37
  98. package/web/src/utils.ts +0 -107
  99. package/web/tsconfig.app.json +0 -25
  100. package/web/tsconfig.json +0 -7
  101. package/web/tsconfig.node.json +0 -24
  102. package/web/vite.config.ts +0 -16
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { Note, Store } from "../core/src/types.ts";
3
+ import { appendContextPart, fetchContextEntries } from "./context.ts";
4
+
5
+ function mkNote(overrides: Partial<Note>): Note {
6
+ return {
7
+ id: overrides.id ?? "n1",
8
+ content: "",
9
+ createdAt: "2026-04-20T00:00:00Z",
10
+ updatedAt: "2026-04-20T00:00:00Z",
11
+ tags: [],
12
+ metadata: {},
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ function mkStore(byTag: Record<string, Note[]>): Store {
18
+ return {
19
+ queryNotes: async ({ tags, excludeTags }) => {
20
+ const tag = tags?.[0];
21
+ if (!tag) return [];
22
+ const pool = byTag[tag] ?? [];
23
+ if (!excludeTags?.length) return pool;
24
+ const excluded = new Set(excludeTags);
25
+ return pool.filter((n) => !(n.tags ?? []).some((t) => excluded.has(t)));
26
+ },
27
+ } as unknown as Store;
28
+ }
29
+
30
+ describe("fetchContextEntries", () => {
31
+ test("returns whitelisted metadata keyed on path basename", async () => {
32
+ const store = mkStore({
33
+ person: [
34
+ mkNote({
35
+ id: "p1",
36
+ path: "People/Aaron.md",
37
+ tags: ["person"],
38
+ metadata: { summary: "founder", aliases: ["A"], secret: "don't leak" },
39
+ }),
40
+ ],
41
+ });
42
+
43
+ const payload = await fetchContextEntries(store, [
44
+ { tag: "person", include_metadata: ["summary", "aliases"] },
45
+ ]);
46
+
47
+ expect(payload.entries.length).toBe(1);
48
+ expect(payload.entries[0].name).toBe("Aaron");
49
+ expect(payload.entries[0].summary).toBe("founder");
50
+ expect(payload.entries[0].aliases).toEqual(["A"]);
51
+ // Non-whitelisted metadata never surfaces.
52
+ expect(payload.entries[0].secret).toBeUndefined();
53
+ });
54
+
55
+ test("honors exclude_tag", async () => {
56
+ const store = mkStore({
57
+ project: [
58
+ mkNote({ id: "pj1", path: "Projects/Active.md", tags: ["project"] }),
59
+ mkNote({ id: "pj2", path: "Projects/Old.md", tags: ["project", "archived"] }),
60
+ ],
61
+ });
62
+
63
+ const payload = await fetchContextEntries(store, [
64
+ { tag: "project", exclude_tag: "archived", include_metadata: [] },
65
+ ]);
66
+
67
+ expect(payload.entries.map((e) => e.name)).toEqual(["Active"]);
68
+ });
69
+
70
+ test("dedups notes across predicates by note id (first-match wins)", async () => {
71
+ const overlap = mkNote({ id: "x1", path: "People/X.md", tags: ["person", "project"] });
72
+ const store = mkStore({ person: [overlap], project: [overlap] });
73
+
74
+ const payload = await fetchContextEntries(store, [
75
+ { tag: "person", include_metadata: [] },
76
+ { tag: "project", include_metadata: [] },
77
+ ]);
78
+
79
+ expect(payload.entries.length).toBe(1);
80
+ expect(payload.entries[0].name).toBe("X");
81
+ });
82
+
83
+ test("falls back to note.id when path is absent", async () => {
84
+ const store = mkStore({
85
+ person: [mkNote({ id: "no-path-note", path: undefined, tags: ["person"] })],
86
+ });
87
+
88
+ const payload = await fetchContextEntries(store, [{ tag: "person" }]);
89
+
90
+ expect(payload.entries[0].name).toBe("no-path-note");
91
+ });
92
+
93
+ test("skips predicates with empty tag (defensive — not throw)", async () => {
94
+ const store = mkStore({ person: [mkNote({ id: "p1", path: "x" })] });
95
+ const payload = await fetchContextEntries(store, [
96
+ { tag: "" },
97
+ { tag: "person" },
98
+ ]);
99
+ expect(payload.entries.length).toBe(1);
100
+ });
101
+
102
+ test("logs and continues on queryNotes throw; does not abort the whole fetch", async () => {
103
+ const errors: unknown[] = [];
104
+ const logger = { error: (...args: unknown[]) => errors.push(args) };
105
+ const store: Store = {
106
+ queryNotes: async ({ tags }) => {
107
+ if (tags?.[0] === "broken") throw new Error("boom");
108
+ return [mkNote({ id: "ok", path: "Ok.md", tags: ["ok"] })];
109
+ },
110
+ } as unknown as Store;
111
+
112
+ const payload = await fetchContextEntries(
113
+ store,
114
+ [{ tag: "broken" }, { tag: "ok" }],
115
+ logger,
116
+ );
117
+
118
+ expect(errors.length).toBe(1);
119
+ expect(payload.entries.map((e) => e.name)).toEqual(["Ok"]);
120
+ });
121
+ });
122
+
123
+ describe("appendContextPart", () => {
124
+ test("appends a JSON blob when entries exist", () => {
125
+ const form = new FormData();
126
+ appendContextPart(form, { entries: [{ name: "x" }] });
127
+ const part = form.get("context");
128
+ expect(part).toBeInstanceOf(Blob);
129
+ });
130
+
131
+ test("no-ops on zero-entries payload", () => {
132
+ const form = new FormData();
133
+ appendContextPart(form, { entries: [] });
134
+ expect(form.get("context")).toBeNull();
135
+ });
136
+ });
package/src/context.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared "vault-provided context" helpers for outbound webhook/worker calls.
3
+ *
4
+ * Triggers (`include_context`) and the transcription worker (`transcription.context`
5
+ * in vault.yaml) both send vault context to an external caller so the caller
6
+ * does not need to reach back into vault to fetch person/project/etc. notes
7
+ * on its own. The shape is caller-agnostic — the receiver just gets a JSON
8
+ * blob of `{ entries: [...] }` — so this module doesn't know anything about
9
+ * scribe specifically.
10
+ *
11
+ * ## Predicate shape
12
+ *
13
+ * include_context:
14
+ * - tag: person
15
+ * exclude_tag: archived
16
+ * include_metadata: [summary, aliases]
17
+ *
18
+ * Each predicate is a query (scoped by `tag`, optionally excluding `exclude_tag`)
19
+ * plus a whitelist of metadata fields to surface on the resulting entries.
20
+ * Fields not in `include_metadata` are dropped. `name` is always included and
21
+ * is the note's path basename (or id, if no path).
22
+ *
23
+ * ## Output
24
+ *
25
+ * {"entries": [{"name": "Aaron", "aliases": ["A"], "summary": "..."}, ...]}
26
+ */
27
+
28
+ import type { Store } from "../core/src/types.ts";
29
+
30
+ export interface ContextPredicate {
31
+ /** Tag the note must carry. Required — a predicate with no tag is a no-op. */
32
+ tag: string;
33
+ /** If set, notes with this tag are excluded. */
34
+ exclude_tag?: string;
35
+ /** Metadata keys to pass through on each entry. Unknown keys are ignored. */
36
+ include_metadata?: string[];
37
+ }
38
+
39
+ export interface ContextEntry {
40
+ /** Note path basename, or note id if no path. */
41
+ name: string;
42
+ /** Whitelisted metadata fields from the predicate. */
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ export interface ContextPayload {
47
+ entries: ContextEntry[];
48
+ }
49
+
50
+ /**
51
+ * Query the vault for notes matching each predicate, project to the whitelisted
52
+ * metadata fields, and return a combined payload. Predicates are run in order;
53
+ * duplicate notes (same id across predicates) are included once by first match.
54
+ *
55
+ * Errors on a single predicate are logged and skipped — a malformed predicate
56
+ * should not take down a whole trigger or worker cycle.
57
+ */
58
+ export async function fetchContextEntries(
59
+ store: Store,
60
+ predicates: ContextPredicate[],
61
+ logger: { error: (...args: unknown[]) => void } = console,
62
+ ): Promise<ContextPayload> {
63
+ const seen = new Set<string>();
64
+ const entries: ContextEntry[] = [];
65
+
66
+ for (const pred of predicates) {
67
+ if (!pred.tag) continue;
68
+ let notes;
69
+ try {
70
+ notes = await store.queryNotes({
71
+ tags: [pred.tag],
72
+ excludeTags: pred.exclude_tag ? [pred.exclude_tag] : undefined,
73
+ });
74
+ } catch (err) {
75
+ logger.error(`[context] query failed for tag="${pred.tag}":`, err);
76
+ continue;
77
+ }
78
+
79
+ for (const note of notes) {
80
+ if (seen.has(note.id)) continue;
81
+ seen.add(note.id);
82
+
83
+ const entry: ContextEntry = { name: nameForNote(note.path, note.id) };
84
+ const meta = (note.metadata as Record<string, unknown> | undefined) ?? {};
85
+ for (const key of pred.include_metadata ?? []) {
86
+ if (key in meta) entry[key] = meta[key];
87
+ }
88
+ entries.push(entry);
89
+ }
90
+ }
91
+
92
+ return { entries };
93
+ }
94
+
95
+ function nameForNote(path: string | undefined, id: string): string {
96
+ if (!path) return id;
97
+ const base = path.split("/").pop() ?? path;
98
+ // Drop extension if present — same rule a UI would apply for display.
99
+ const dot = base.lastIndexOf(".");
100
+ return dot > 0 ? base.slice(0, dot) : base;
101
+ }
102
+
103
+ /**
104
+ * Append a `context` multipart part to the given FormData. Does nothing if
105
+ * the payload has no entries (avoids a zero-entries part that the receiver
106
+ * would have to special-case).
107
+ */
108
+ export function appendContextPart(form: FormData, payload: ContextPayload): void {
109
+ if (!payload.entries.length) return;
110
+ form.append(
111
+ "context",
112
+ new Blob([JSON.stringify(payload)], { type: "application/json" }),
113
+ "context.json",
114
+ );
115
+ }
package/src/daemon.ts CHANGED
@@ -5,25 +5,26 @@
5
5
  * Rationale: both platforms historically hardcoded the absolute path to
6
6
  * `src/server.ts` into their respective service/wrapper files at init
7
7
  * time. When the repo moved, the service kept respawning bun on a missing
8
- * file and crash-looped silently into `~/.parachute/vault.err`.
8
+ * file and crash-looped silently into `~/.parachute/vault/vault.err`.
9
9
  *
10
- * The fix: a small bash wrapper (`~/.parachute/start.sh`) and a pointer
11
- * file (`~/.parachute/server-path`) that the wrapper reads at every boot.
12
- * Moving the repo now only requires re-running `parachute vault init` —
13
- * no plist/unit re-registration needed. Callers can override the path for
14
- * a single boot via `PARACHUTE_VAULT_SERVER_PATH` in `~/.parachute/.env`.
10
+ * The fix: a small bash wrapper (`~/.parachute/vault/start.sh`) and a
11
+ * pointer file (`~/.parachute/vault/server-path`) that the wrapper reads
12
+ * at every boot. Moving the repo now only requires re-running
13
+ * `parachute-vault init` — no plist/unit re-registration needed. Callers
14
+ * can override the path for a single boot via `PARACHUTE_VAULT_SERVER_PATH`
15
+ * in `~/.parachute/vault/.env`.
15
16
  */
16
17
 
17
18
  import { homedir } from "os";
18
19
  import { join, resolve, dirname } from "path";
19
20
  import { writeFile, unlink } from "fs/promises";
20
21
  import { existsSync, readFileSync } from "fs";
21
- import { CONFIG_DIR, ENV_PATH } from "./config.ts";
22
+ import { VAULT_HOME, ENV_PATH } from "./config.ts";
22
23
 
23
24
  /** Start-up script sourced by launchd and systemd alike. */
24
- export const WRAPPER_PATH = join(CONFIG_DIR, "start.sh");
25
+ export const WRAPPER_PATH = join(VAULT_HOME, "start.sh");
25
26
  /** Pointer file. Contents: absolute path to `server.ts`, one line. */
26
- export const SERVER_PATH_FILE = join(CONFIG_DIR, "server-path");
27
+ export const SERVER_PATH_FILE = join(VAULT_HOME, "server-path");
27
28
 
28
29
  /**
29
30
  * Build the start.sh contents. Pure string builder — easy to assert on.
@@ -31,7 +32,7 @@ export const SERVER_PATH_FILE = join(CONFIG_DIR, "server-path");
31
32
  * The generated script:
32
33
  * 1. Sources the user's login shell profiles to pick up PATH (needed
33
34
  * when the daemon shells out to tools like ffmpeg, parakeet-mlx).
34
- * 2. Sources ~/.parachute/.env.
35
+ * 2. Sources ~/.parachute/vault/.env.
35
36
  * 3. Resolves the server path: env override first, then pointer file.
36
37
  * 4. Fails loudly with an actionable message if the path is missing or
37
38
  * the target file doesn't exist — instead of silently respawning
@@ -46,10 +47,10 @@ export function generateWrapper(opts: {
46
47
  const envPath = opts.envPath ?? ENV_PATH;
47
48
  const pointer = opts.serverPathFile ?? SERVER_PATH_FILE;
48
49
  return `#!/bin/bash
49
- # Auto-generated by \`parachute vault init\`. Do not edit by hand — edits
50
+ # Auto-generated by \`parachute-vault init\`. Do not edit by hand — edits
50
51
  # are clobbered on the next init. To override the server path for a
51
- # single boot, set PARACHUTE_VAULT_SERVER_PATH in ~/.parachute/.env.
52
- # To point at a different repo permanently, re-run \`parachute vault init\`
52
+ # single boot, set PARACHUTE_VAULT_SERVER_PATH in ~/.parachute/vault/.env.
53
+ # To point at a different repo permanently, re-run \`parachute-vault init\`
53
54
  # from that repo.
54
55
 
55
56
  set -u
@@ -61,7 +62,7 @@ set -u
61
62
  # empty because of the 2>/dev/null below — launchd would respawn silently
62
63
  # until it gave up. Keep the stderr redirect so expected "command not found"
63
64
  # noise from incomplete setups doesn't fill vault.err; to debug silent
64
- # wrapper failures, run \`bash -x ~/.parachute/start.sh\` by hand.
65
+ # wrapper failures, run \`bash -x ~/.parachute/vault/start.sh\` by hand.
65
66
  set +u
66
67
  [ -f "$HOME/.zprofile" ] && source "$HOME/.zprofile" 2>/dev/null
67
68
  [ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null
@@ -81,13 +82,13 @@ fi
81
82
 
82
83
  if [ -z "$SERVER_PATH" ]; then
83
84
  echo "parachute-vault: server path not configured (no ${pointer} and PARACHUTE_VAULT_SERVER_PATH is unset)." >&2
84
- echo "parachute-vault: run \\\`parachute vault init\\\` to configure it." >&2
85
+ echo "parachute-vault: run \\\`parachute-vault init\\\` to configure it." >&2
85
86
  exit 1
86
87
  fi
87
88
 
88
89
  if [ ! -f "$SERVER_PATH" ]; then
89
90
  echo "parachute-vault: server.ts not found at $SERVER_PATH" >&2
90
- echo "parachute-vault: the repo may have moved. Run \\\`parachute vault init\\\` from the current repo location." >&2
91
+ echo "parachute-vault: the repo may have moved. Run \\\`parachute-vault init\\\` from the current repo location." >&2
91
92
  exit 1
92
93
  fi
93
94
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Integration tests for `parachute vault doctor` and `parachute vault url`.
2
+ * Integration tests for `parachute-vault doctor` and `parachute-vault url`.
3
3
  *
4
4
  * We spawn the CLI as a subprocess with `PARACHUTE_HOME` pointed at a
5
5
  * fresh tempdir — so each test exercises the real code path (config +
@@ -74,7 +74,7 @@ describe("vault doctor", () => {
74
74
  expect(res.exitCode).toBe(1);
75
75
  expect(res.stdout).toMatch(/server-path pointer/);
76
76
  expect(res.stdout).toMatch(/missing/);
77
- expect(res.stdout).toMatch(/parachute vault init/);
77
+ expect(res.stdout).toMatch(/parachute-vault init/);
78
78
  });
79
79
 
80
80
  test("fails when the pointer targets a non-existent file (moved repo)", () => {
@@ -172,7 +172,7 @@ describe("vault doctor — extended checks", () => {
172
172
  // Use a non-default port to prove we're actually reading config.yaml,
173
173
  // not just matching against DEFAULT_PORT.
174
174
  writeFileSync(join(dir, "config.yaml"), "port: 4321\n");
175
- writeClaudeJson(dir, "http://127.0.0.1:4321/vaults/default/mcp");
175
+ writeClaudeJson(dir, "http://127.0.0.1:4321/vault/default/mcp");
176
176
  const res = runCli(["doctor"], dir, { HOME: dir });
177
177
  expect(res.stdout).toMatch(/✓ MCP entry in ~\/\.claude\.json/);
178
178
  expect(res.stdout).toMatch(/✓ MCP URL port matches vault\s+\(port 4321\)/);
@@ -184,7 +184,7 @@ describe("vault doctor — extended checks", () => {
184
184
 
185
185
  test("warns when MCP URL port does not match the vault's configured port", () => {
186
186
  writeFileSync(join(dir, "config.yaml"), "port: 4321\n");
187
- writeClaudeJson(dir, "http://127.0.0.1:9999/vaults/default/mcp");
187
+ writeClaudeJson(dir, "http://127.0.0.1:9999/vault/default/mcp");
188
188
  const res = runCli(["doctor"], dir, { HOME: dir });
189
189
  expect(res.stdout).toMatch(/✓ MCP entry in ~\/\.claude\.json/);
190
190
  expect(res.stdout).toMatch(/! MCP URL port matches vault/);
@@ -304,9 +304,11 @@ describe("vault uninstall", () => {
304
304
  });
305
305
 
306
306
  test("answering 'no' at the prompt does not touch daemon/filesystem", async () => {
307
- // Set up a fake install: wrapper + pointer in the temp PARACHUTE_HOME.
308
- const wrapper = join(dir, "start.sh");
309
- const pointer = join(dir, "server-path");
307
+ // Set up a fake install: wrapper + pointer in the vault/ subdir of the
308
+ // temp PARACHUTE_HOME (the post-0.3 layout).
309
+ mkdirSync(join(dir, "vault"), { recursive: true });
310
+ const wrapper = join(dir, "vault", "start.sh");
311
+ const pointer = join(dir, "vault", "server-path");
310
312
  writeFileSync(wrapper, "#!/bin/bash\n");
311
313
  writeFileSync(pointer, "/tmp/fake.ts\n");
312
314
 
@@ -40,7 +40,7 @@ describe("generateWrapper", () => {
40
40
  expect(wrapper).toContain('if [ -z "$SERVER_PATH" ]; then');
41
41
  expect(wrapper).toContain("exit 1");
42
42
  // Actionable message — user needs to know what to run.
43
- expect(wrapper).toMatch(/parachute vault init/);
43
+ expect(wrapper).toMatch(/parachute-vault init/);
44
44
  });
45
45
 
46
46
  test("fails loudly when pointer target no longer exists (moved repo)", () => {
package/src/launchd.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * macOS launchd agent management for the vault daemon.
3
3
  *
4
- * The plist runs `~/.parachute/start.sh` (the shared wrapper from
4
+ * The plist runs `~/.parachute/vault/start.sh` (the shared wrapper from
5
5
  * daemon.ts). The wrapper reads the pointer file at every boot, so
6
- * moving the repo only requires re-running `parachute vault init`.
6
+ * moving the repo only requires re-running `parachute-vault init`.
7
7
  */
8
8
 
9
9
  import { homedir } from "os";
10
10
  import { join } from "path";
11
11
  import { writeFile, unlink } from "fs/promises";
12
12
  import { $ } from "bun";
13
- import { CONFIG_DIR, LOG_PATH, ERR_PATH } from "./config.ts";
13
+ import { VAULT_HOME, LOG_PATH, ERR_PATH } from "./config.ts";
14
14
  import { WRAPPER_PATH, writeDaemonWrapper } from "./daemon.ts";
15
15
 
16
16
  const LABEL = "computer.parachute.vault";
@@ -37,7 +37,7 @@ export function generatePlist(): string {
37
37
  <key>StandardErrorPath</key>
38
38
  <string>${ERR_PATH}</string>
39
39
  <key>WorkingDirectory</key>
40
- <string>${CONFIG_DIR}</string>
40
+ <string>${VAULT_HOME}</string>
41
41
  </dict>
42
42
  </plist>`;
43
43
  }
@@ -45,7 +45,7 @@ export function generatePlist(): string {
45
45
  /**
46
46
  * Install or re-install the launchd agent. Idempotent: if the agent is
47
47
  * already loaded, it's unloaded first so the new wrapper + pointer take
48
- * effect. This is what makes `parachute vault init` safe to re-run after
48
+ * effect. This is what makes `parachute-vault init` safe to re-run after
49
49
  * a folder move — the incident that motivated this PR.
50
50
  */
51
51
  export async function installAgent(): Promise<{ serverPath: string }> {
@@ -86,7 +86,7 @@ export async function uninstallAgent(): Promise<void> {
86
86
  // Linux uninstall path. Callers that want a fully-clean teardown must
87
87
  // also call `removeDaemonWrapper()` — the CLI's `uninstall` command in
88
88
  // PR 3 wires that up. Leaving them here programmatically would strand
89
- // orphaned files in `~/.parachute/`.
89
+ // orphaned files in `~/.parachute/vault/`.
90
90
  }
91
91
 
92
92
  export async function isAgentLoaded(): Promise<boolean> {
package/src/mcp-http.ts CHANGED
@@ -10,12 +10,9 @@
10
10
  * root cause of vault#56. The `initialize` method still works if a
11
11
  * client sends it (the Server class handles it natively).
12
12
  *
13
- * Two modes:
14
- * /mcp — unified, all vaults via `vault` param + list-vaults
15
- * /vaults/{name}/mcp scoped to one vault, no vault param
16
- *
17
- * Vault description is sent as the MCP server instruction.
18
- * Read-only keys see fewer tools.
13
+ * Every MCP session is scoped to one vault via `/vault/{name}/mcp`.
14
+ * The vault's description is sent as the MCP server instruction, and
15
+ * read-only keys see a filtered tool list.
19
16
  */
20
17
 
21
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -23,22 +20,45 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
23
20
  import {
24
21
  ListToolsRequestSchema,
25
22
  CallToolRequestSchema,
23
+ ErrorCode,
24
+ McpError,
26
25
  } from "@modelcontextprotocol/sdk/types.js";
27
- import { generateUnifiedMcpTools, generateScopedMcpTools, getServerInstruction } from "./mcp-tools.ts";
28
- import { isToolAllowed } from "./auth.ts";
26
+ import { generateScopedMcpTools, getServerInstruction } from "./mcp-tools.ts";
27
+ import { requireScope } from "./auth.ts";
29
28
  import type { AuthResult } from "./auth.ts";
30
29
  import type { McpToolDef } from "../core/src/mcp.ts";
30
+ import { SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
31
+
32
+ /**
33
+ * Required scope for each MCP tool. Tools that mutate note/tag state require
34
+ * `vault:write`; pure query tools need `vault:read`. `vault-info` is listed as
35
+ * read because read-only callers can fetch stats — the description-update
36
+ * branch inside vault-info performs its own secondary `vault:write` check
37
+ * (see `overrideVaultInfo` in mcp-tools.ts). Do not assume the outer gate
38
+ * alone protects the inner branch.
39
+ */
40
+ const TOOL_REQUIRED_SCOPE: Record<string, string> = {
41
+ "query-notes": SCOPE_READ,
42
+ "list-tags": SCOPE_READ,
43
+ "find-path": SCOPE_READ,
44
+ "vault-info": SCOPE_READ,
45
+ "create-note": SCOPE_WRITE,
46
+ "update-note": SCOPE_WRITE,
47
+ "delete-note": SCOPE_WRITE,
48
+ "update-tag": SCOPE_WRITE,
49
+ "delete-tag": SCOPE_WRITE,
50
+ };
31
51
 
32
- /** Handle unified MCP at /mcp (all vaults). */
33
- export async function handleUnifiedMcp(req: Request, auth: AuthResult): Promise<Response> {
34
- const instruction = getServerInstruction();
35
- return handleMcp(req, () => generateUnifiedMcpTools(), "parachute-vault", auth, instruction);
52
+ function requiredScopeForTool(toolName: string): string {
53
+ // Default-deny: unknown tools require write. Keeps accidental reads of
54
+ // a not-yet-mapped mutation tool from slipping past.
55
+ return TOOL_REQUIRED_SCOPE[toolName] ?? SCOPE_WRITE;
36
56
  }
37
57
 
38
- /** Handle scoped MCP at /vaults/{name}/mcp (single vault). */
58
+ /** Handle scoped MCP at /vault/{name}/mcp (single vault). */
39
59
  export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
40
60
  const instruction = getServerInstruction(vaultName);
41
- return handleMcp(req, () => generateScopedMcpTools(vaultName), `parachute-vault/${vaultName}`, auth, instruction);
61
+ return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, auth, instruction);
42
62
  }
43
63
 
44
64
  async function handleMcp(
@@ -48,7 +68,6 @@ async function handleMcp(
48
68
  auth: AuthResult,
49
69
  instruction: string,
50
70
  ): Promise<Response> {
51
- const { permission } = auth;
52
71
  const transport = new WebStandardStreamableHTTPServerTransport({
53
72
  sessionIdGenerator: undefined,
54
73
  enableJsonResponse: true,
@@ -64,10 +83,13 @@ async function handleMcp(
64
83
 
65
84
  const mcpTools = getTools();
66
85
 
67
- // For read-only keys, only list readable tools
68
- const visibleTools = permission === "read"
69
- ? mcpTools.filter((t) => isToolAllowed(t.name, "read"))
70
- : mcpTools;
86
+ // Filter the advertised tool list to what the caller's scopes actually
87
+ // permit. Callers without `vault:write` don't see mutation tools at all —
88
+ // matches the prior behavior of the read/full permission model but is now
89
+ // driven by scope inheritance.
90
+ const visibleTools = mcpTools.filter((t) =>
91
+ requireScope(auth, requiredScopeForTool(t.name)),
92
+ );
71
93
 
72
94
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
73
95
  tools: visibleTools.map((t) => ({
@@ -80,9 +102,13 @@ async function handleMcp(
80
102
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
81
103
  const { name, arguments: args } = request.params;
82
104
 
83
- if (!isToolAllowed(name, permission)) {
105
+ const neededScope = requiredScopeForTool(name);
106
+ if (!requireScope(auth, neededScope)) {
84
107
  return {
85
- content: [{ type: "text" as const, text: `Forbidden: insufficient permissions to call ${name}` }],
108
+ content: [{
109
+ type: "text" as const,
110
+ text: `Forbidden: tool '${name}' requires the '${neededScope}' scope. Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
111
+ }],
86
112
  isError: true,
87
113
  };
88
114
  }
@@ -100,7 +126,35 @@ async function handleMcp(
100
126
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
101
127
  };
102
128
  } catch (err) {
129
+ // Domain errors from the core tools (conflict, missing precondition) get
130
+ // surfaced as JSON-RPC errors with a structured `data` field so an
131
+ // agent can key off `data.error_type` and the concurrency tokens.
132
+ // Everything else falls through to an in-band tool error with
133
+ // `isError: true` — legible but unstructured.
103
134
  const message = err instanceof Error ? err.message : "Unknown error";
135
+ const e = err as {
136
+ code?: string;
137
+ note_id?: string;
138
+ note_path?: string | null;
139
+ current_updated_at?: string | null;
140
+ expected_updated_at?: string;
141
+ };
142
+ if (e?.code === "CONFLICT") {
143
+ throw new McpError(ErrorCode.InvalidRequest, message, {
144
+ error_type: "conflict",
145
+ current_updated_at: e.current_updated_at ?? null,
146
+ your_updated_at: e.expected_updated_at,
147
+ path: e.note_path ?? null,
148
+ note_id: e.note_id,
149
+ });
150
+ }
151
+ if (e?.code === "PRECONDITION_REQUIRED") {
152
+ throw new McpError(ErrorCode.InvalidParams, message, {
153
+ error_type: "precondition_required",
154
+ note_id: e.note_id,
155
+ path: e.note_path ?? null,
156
+ });
157
+ }
104
158
  return {
105
159
  content: [{ type: "text" as const, text: `Error: ${message}` }],
106
160
  isError: true,