@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/mcp-http.ts CHANGED
@@ -24,47 +24,51 @@ import {
24
24
  McpError,
25
25
  } from "@modelcontextprotocol/sdk/types.js";
26
26
  import { generateScopedMcpTools, getServerInstruction } from "./mcp-tools.ts";
27
- import { requireScope } from "./auth.ts";
28
27
  import type { AuthResult } from "./auth.ts";
29
28
  import type { McpToolDef } from "../core/src/mcp.ts";
30
- import { SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
29
+ import { hasScopeForVault } from "./scopes.ts";
30
+ import type { VaultVerb } from "./scopes.ts";
31
31
 
32
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.
33
+ * Required verb for each MCP tool. Tools that mutate note/tag state require
34
+ * write; pure query tools need read. `vault-info` is listed as read because
35
+ * read-only callers can fetch stats — the description-update branch inside
36
+ * vault-info performs its own secondary write check (see `overrideVaultInfo`
37
+ * in mcp-tools.ts). Do not assume the outer gate alone protects the inner
38
+ * branch.
39
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,
40
+ const TOOL_REQUIRED_VERB: Record<string, VaultVerb> = {
41
+ "query-notes": "read",
42
+ "list-tags": "read",
43
+ "find-path": "read",
44
+ "vault-info": "read",
45
+ "create-note": "write",
46
+ "update-note": "write",
47
+ "delete-note": "write",
48
+ "update-tag": "write",
49
+ "delete-tag": "write",
50
50
  };
51
51
 
52
- function requiredScopeForTool(toolName: string): string {
52
+ function requiredVerbForTool(toolName: string): VaultVerb {
53
53
  // Default-deny: unknown tools require write. Keeps accidental reads of
54
54
  // a not-yet-mapped mutation tool from slipping past.
55
- return TOOL_REQUIRED_SCOPE[toolName] ?? SCOPE_WRITE;
55
+ return TOOL_REQUIRED_VERB[toolName] ?? "write";
56
56
  }
57
57
 
58
58
  /** Handle scoped MCP at /vault/{name}/mcp (single vault). */
59
59
  export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
60
- const instruction = getServerInstruction(vaultName);
61
- return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, auth, instruction);
60
+ // Auth flows through to getServerInstruction so the connect-time
61
+ // markdown brief is filtered by `scoped_tags` symmetric with the
62
+ // JSON `vault-info` wrapper.
63
+ const instruction = await getServerInstruction(vaultName, auth);
64
+ return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, vaultName, auth, instruction);
62
65
  }
63
66
 
64
67
  async function handleMcp(
65
68
  req: Request,
66
69
  getTools: () => McpToolDef[],
67
70
  serverName: string,
71
+ vaultName: string,
68
72
  auth: AuthResult,
69
73
  instruction: string,
70
74
  ): Promise<Response> {
@@ -84,11 +88,11 @@ async function handleMcp(
84
88
  const mcpTools = getTools();
85
89
 
86
90
  // 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.
91
+ // permit for THIS vault. Callers without write don't see mutation tools at
92
+ // all — matches the prior behavior of the read/full permission model but
93
+ // now driven by per-vault scope inheritance.
90
94
  const visibleTools = mcpTools.filter((t) =>
91
- requireScope(auth, requiredScopeForTool(t.name)),
95
+ hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t.name)),
92
96
  );
93
97
 
94
98
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -102,12 +106,12 @@ async function handleMcp(
102
106
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
103
107
  const { name, arguments: args } = request.params;
104
108
 
105
- const neededScope = requiredScopeForTool(name);
106
- if (!requireScope(auth, neededScope)) {
109
+ const neededVerb = requiredVerbForTool(name);
110
+ if (!hasScopeForVault(auth.scopes, vaultName, neededVerb)) {
107
111
  return {
108
112
  content: [{
109
113
  type: "text" as const,
110
- text: `Forbidden: tool '${name}' requires the '${neededScope}' scope. Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
114
+ text: `Forbidden: tool '${name}' requires the 'vault:${neededVerb}' scope (or 'vault:${vaultName}:${neededVerb}'). Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
111
115
  }],
112
116
  isError: true,
113
117
  };
@@ -20,7 +20,7 @@ export type McpUrlSource = "hub-origin" | "expose-state" | "loopback";
20
20
  export function chooseMcpUrl(
21
21
  vaultName: string,
22
22
  port: number,
23
- env: { PARACHUTE_HUB_ORIGIN?: string } = process.env,
23
+ env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
24
24
  ): { url: string; source: McpUrlSource } {
25
25
  const hub = env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
26
26
  if (hub) {
package/src/mcp-tools.ts CHANGED
@@ -7,27 +7,88 @@
7
7
 
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef } from "../core/src/mcp.ts";
10
+ import {
11
+ buildVaultProjection,
12
+ projectionToMarkdown,
13
+ type VaultProjection,
14
+ } from "../core/src/vault-projection.ts";
10
15
  import { readVaultConfig, writeVaultConfig } from "./config.ts";
11
16
  import { getVaultStore } from "./vault-store.ts";
12
- import { hasScope, SCOPE_WRITE } from "./scopes.ts";
17
+ import { hasScopeForVault } from "./scopes.ts";
13
18
  import type { AuthResult } from "./auth.ts";
19
+ import {
20
+ expandTokenTagScope,
21
+ noteWithinTagScope,
22
+ tagsWithinScope,
23
+ } from "./tag-scope.ts";
24
+ import { findTokensReferencingTag } from "./token-store.ts";
25
+
26
+ /**
27
+ * Filter a vault projection to entries an in-scope tag contributes to.
28
+ *
29
+ * Mirrors the JSON `vault-info` wrapper exactly so the connect-time
30
+ * markdown brief and the JSON tool surface identical scope shapes:
31
+ *
32
+ * - `tags` array → drop entries whose name isn't in `allowed`.
33
+ * - `indexed_fields` array → for each entry, intersect `tags` (the
34
+ * declarer list) with `allowed`. Drop the entry entirely when no
35
+ * declarer survives.
36
+ *
37
+ * Aggregate stats and the static `query_hints` catalog pass through
38
+ * unchanged — counts are aggregate (already pre-#271 behavior) and hints
39
+ * are pure documentation.
40
+ */
41
+ function filterProjectionByScope(
42
+ projection: VaultProjection,
43
+ allowed: Set<string>,
44
+ ): VaultProjection {
45
+ return {
46
+ ...projection,
47
+ tags: projection.tags.filter((t) => allowed.has(t.name)),
48
+ indexed_fields: projection.indexed_fields
49
+ .map((f) => ({ ...f, tags: f.tags.filter((t) => allowed.has(t)) }))
50
+ .filter((f) => f.tags.length > 0),
51
+ };
52
+ }
14
53
 
15
54
  /**
16
55
  * Get the MCP server instruction for a vault.
17
- * Sent once at session init — not per tool.
56
+ *
57
+ * Sent once at session init via the MCP `initialize` response — not per
58
+ * tool. The body is a markdown brief composed from the same vault projection
59
+ * `vault-info` returns, so an agent has everything it needs to orient
60
+ * itself before issuing a single query. Stats are included so the count
61
+ * line ("N notes, M tags") is always populated.
62
+ *
63
+ * When `auth` carries `scoped_tags`, the projection is filtered to those
64
+ * tags + descendants before rendering — symmetric with the JSON
65
+ * `vault-info` wrapper, so a tag-scoped token never learns about
66
+ * out-of-scope tags via the connect-time brief either. Aggregate counts
67
+ * pass through unchanged (they were pre-existing leak surface; not new).
68
+ *
69
+ * Async because expanding the tag-scope allowlist hits the store's
70
+ * hierarchy resolver. Returns the orientation block even when the vault
71
+ * has no description or schemas — empty vaults still get the query-hint
72
+ * catalog and refresh pointers.
18
73
  */
19
- export function getServerInstruction(vaultName: string): string {
74
+ export async function getServerInstruction(
75
+ vaultName: string,
76
+ auth?: AuthResult,
77
+ ): Promise<string> {
20
78
  const config = readVaultConfig(vaultName);
79
+ const store = getVaultStore(vaultName);
80
+ let projection = buildVaultProjection(store.db, { includeStats: true });
21
81
 
22
- const parts: string[] = [
23
- `You are connected to Parachute Vault "${vaultName}".`,
24
- ];
25
-
26
- if (config?.description) {
27
- parts.push("", config.description);
82
+ if (auth?.scoped_tags && auth.scoped_tags.length > 0) {
83
+ const allowed = await expandTokenTagScope(store, auth.scoped_tags);
84
+ if (allowed) projection = filterProjectionByScope(projection, allowed);
28
85
  }
29
86
 
30
- return parts.join("\n");
87
+ return projectionToMarkdown({
88
+ vaultName,
89
+ description: config?.description ?? null,
90
+ projection,
91
+ });
31
92
  }
32
93
 
33
94
  /**
@@ -46,10 +107,241 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
46
107
  const tools = generateMcpTools(store);
47
108
 
48
109
  overrideVaultInfo(tools, vaultName, auth);
110
+ applyTagDependencyGuards(tools, vaultName);
111
+ applyTagScopeWrappers(tools, vaultName, auth);
49
112
 
50
113
  return tools;
51
114
  }
52
115
 
116
+ /**
117
+ * Tag-delete and (future) tag-merge always check for tag-scoped tokens
118
+ * referencing the doomed tag — regardless of whether the *deleter* is
119
+ * itself tag-scoped. A successful delete that orphans an allowlist would
120
+ * silently widen surface area downstream. Mirrors the REST 409
121
+ * `tag_in_use_by_tokens` envelope.
122
+ */
123
+ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
124
+ const store = getVaultStore(vaultName);
125
+ wrapReadTool(tools, "delete-tag", async (orig, params) => {
126
+ const tag = (params as any).tag ?? (params as any).name;
127
+ if (typeof tag === "string") {
128
+ const referenced_by = findTokensReferencingTag(store.db, tag);
129
+ if (referenced_by.length > 0) {
130
+ return {
131
+ error: "TagInUseByTokens",
132
+ error_type: "tag_in_use_by_tokens",
133
+ message: `Tag "${tag}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before deleting.`,
134
+ tag,
135
+ referenced_by,
136
+ };
137
+ }
138
+ }
139
+ return await orig(params);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Wrap read-tool execute() functions to filter results down to what the
145
+ * token's `scoped_tags` allowlist permits. No-op when the token is
146
+ * unscoped — the wrappers fast-path on `auth.scoped_tags === null` so
147
+ * unscoped sessions retain identical pre-tag-scope behavior.
148
+ *
149
+ * Read tools handled here:
150
+ * - query-notes: filter single-note returns + result lists
151
+ * - list-tags: filter to allowlisted tags + descendants
152
+ * - find-path: require both endpoints (and every hop) in scope
153
+ * - vault-info: filter projection.tags + projection.indexed_fields
154
+ * to entries an in-scope tag contributes to
155
+ *
156
+ * Write-tool gating happens in handleScopedMcp at the verb-scope layer
157
+ * AND inside each tool's wrapper here (so a tag-scoped `vault:write`
158
+ * token can't write outside its allowlist). See applyTagScopeWriteGuards.
159
+ */
160
+ function applyTagScopeWrappers(
161
+ tools: McpToolDef[],
162
+ vaultName: string,
163
+ auth: AuthResult | undefined,
164
+ ): void {
165
+ if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
166
+ const store = getVaultStore(vaultName);
167
+ // Lazy: only build the expanded allowlist on first tool call.
168
+ let allowedPromise: Promise<Set<string> | null> | null = null;
169
+ const getAllowed = (): Promise<Set<string> | null> => {
170
+ if (!allowedPromise) {
171
+ allowedPromise = expandTokenTagScope(store, auth.scoped_tags);
172
+ }
173
+ return allowedPromise;
174
+ };
175
+ const rawTags = auth.scoped_tags;
176
+
177
+ wrapReadTool(tools, "query-notes", async (orig, params) => {
178
+ const allowed = await getAllowed();
179
+ const result = await orig(params);
180
+ if (!allowed) return result;
181
+ // Single-note shape (`{...note}` with `id`) vs list shape (array).
182
+ if (Array.isArray(result)) {
183
+ return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
184
+ }
185
+ if (result && typeof result === "object" && "id" in result && "tags" in result) {
186
+ return noteWithinTagScope(result as any, allowed, rawTags)
187
+ ? result
188
+ : { error: "Note not found", id: (result as any).id };
189
+ }
190
+ return result;
191
+ });
192
+
193
+ wrapReadTool(tools, "list-tags", async (orig, params) => {
194
+ const allowed = await getAllowed();
195
+ const result = await orig(params);
196
+ if (!allowed || !Array.isArray(result)) return result;
197
+ return result.filter((t: any) => allowed.has(t.name));
198
+ });
199
+
200
+ wrapReadTool(tools, "find-path", async (orig, params) => {
201
+ const allowed = await getAllowed();
202
+ const result = await orig(params);
203
+ if (!allowed || !result || typeof result !== "object" || !("path" in result)) return result;
204
+ const ids = (result as any).path as string[];
205
+ for (const id of ids) {
206
+ const note = await store.getNote(id);
207
+ if (!note || !noteWithinTagScope(note, allowed, rawTags)) {
208
+ return null;
209
+ }
210
+ }
211
+ return result;
212
+ });
213
+
214
+ // vault-info projection (#271): filter the tags catalog to in-scope tags
215
+ // and the indexed_fields catalog to fields with at least one in-scope
216
+ // declarer. Within each surviving indexed_fields entry, also drop
217
+ // out-of-scope declarer names from the `tags` array — a token scoped to
218
+ // `task` shouldn't learn that `project` declares `status` too. Other
219
+ // top-level keys (name, description, query_hints, stats) pass through:
220
+ // counts are aggregate and existing pre-#271 behavior already returned
221
+ // them to scoped tokens. The same `filterProjectionByScope` helper backs
222
+ // `getServerInstruction` so the JSON tool and the connect-time markdown
223
+ // brief stay in lockstep.
224
+ wrapReadTool(tools, "vault-info", async (orig, params) => {
225
+ const allowed = await getAllowed();
226
+ const result = await orig(params);
227
+ if (!allowed || !result || typeof result !== "object") return result;
228
+ const r = result as Record<string, unknown> & Partial<VaultProjection>;
229
+ const partial: VaultProjection = {
230
+ tags: Array.isArray(r.tags) ? r.tags : [],
231
+ indexed_fields: Array.isArray(r.indexed_fields) ? r.indexed_fields : [],
232
+ query_hints: Array.isArray(r.query_hints) ? r.query_hints : [],
233
+ };
234
+ const filtered = filterProjectionByScope(partial, allowed);
235
+ r.tags = filtered.tags;
236
+ r.indexed_fields = filtered.indexed_fields;
237
+ return r;
238
+ });
239
+
240
+ // ---- Write-side guards ----
241
+ //
242
+ // The verb-scope check (`vault:write`) is enforced at the dispatch layer
243
+ // in handleScopedMcp. These wrappers add the second axis: a scoped
244
+ // `vault:write` token can only mutate within its tag-allowlist, never
245
+ // outside it. Tag operations (`update-tag`, `delete-tag`) gate on the
246
+ // tag name itself; note operations gate on the prospective tag set.
247
+
248
+ const forbidden = (msg: string): unknown => ({
249
+ error: "Forbidden",
250
+ error_type: "tag_scope_violation",
251
+ message: `${msg} (token tag-allowlist: ${rawTags.join(", ")})`,
252
+ scoped_tags: rawTags,
253
+ });
254
+
255
+ wrapReadTool(tools, "create-note", async (orig, params) => {
256
+ const allowed = await getAllowed();
257
+ if (!allowed) return await orig(params);
258
+ // Single or batch shape: `{notes: [...]}` is the batch form (mirrors HTTP).
259
+ const items = Array.isArray((params as any).notes)
260
+ ? (params as any).notes
261
+ : [params];
262
+ for (const item of items) {
263
+ const itemTags = Array.isArray((item as any).tags) ? ((item as any).tags as string[]) : [];
264
+ if (!tagsWithinScope(itemTags, allowed, rawTags)) {
265
+ return forbidden("create-note: every note must carry at least one tag in the token's allowlist");
266
+ }
267
+ }
268
+ return await orig(params);
269
+ });
270
+
271
+ wrapReadTool(tools, "update-note", async (orig, params) => {
272
+ const allowed = await getAllowed();
273
+ if (!allowed) return await orig(params);
274
+ const items = Array.isArray((params as any).notes)
275
+ ? (params as any).notes
276
+ : [params];
277
+ for (const item of items) {
278
+ const id = (item as any).id ?? (item as any).note_id;
279
+ if (!id) continue;
280
+ const existing = await store.getNote(id as string);
281
+ if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
282
+ return { error: "Note not found", id };
283
+ }
284
+ const removed = new Set<string>((item as any).tags?.remove ?? []);
285
+ const projected = new Set<string>((existing.tags ?? []).filter((t) => !removed.has(t)));
286
+ for (const t of ((item as any).tags?.add ?? []) as string[]) projected.add(t);
287
+ if (!tagsWithinScope([...projected], allowed, rawTags)) {
288
+ return forbidden("update-note: post-update tag set must satisfy the token's allowlist");
289
+ }
290
+ }
291
+ return await orig(params);
292
+ });
293
+
294
+ wrapReadTool(tools, "delete-note", async (orig, params) => {
295
+ const allowed = await getAllowed();
296
+ if (!allowed) return await orig(params);
297
+ const id = (params as any).id ?? (params as any).note_id;
298
+ if (id) {
299
+ const existing = await store.getNote(id as string);
300
+ if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
301
+ return { error: "Note not found", id };
302
+ }
303
+ }
304
+ return await orig(params);
305
+ });
306
+
307
+ wrapReadTool(tools, "update-tag", async (orig, params) => {
308
+ const allowed = await getAllowed();
309
+ if (!allowed) return await orig(params);
310
+ const tag = (params as any).tag ?? (params as any).name;
311
+ if (typeof tag === "string" && !allowed.has(tag)) {
312
+ return forbidden(`update-tag: tag "${tag}" is outside the token's allowlist`);
313
+ }
314
+ return await orig(params);
315
+ });
316
+
317
+ wrapReadTool(tools, "delete-tag", async (orig, params) => {
318
+ const allowed = await getAllowed();
319
+ if (!allowed) return await orig(params);
320
+ const tag = (params as any).tag ?? (params as any).name;
321
+ if (typeof tag === "string" && !allowed.has(tag)) {
322
+ return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
323
+ }
324
+ return await orig(params);
325
+ });
326
+
327
+ }
328
+
329
+ function wrapReadTool(
330
+ tools: McpToolDef[],
331
+ name: string,
332
+ wrapper: (orig: (params: Record<string, unknown>) => Promise<unknown>, params: Record<string, unknown>) => Promise<unknown>,
333
+ ): void {
334
+ const tool = tools.find((t) => t.name === name);
335
+ if (!tool) return;
336
+ // McpToolDef.execute returns `unknown | Promise<unknown>` (sync OR async).
337
+ // Adapt to the wrapper's strictly-async signature so wrappers can `await
338
+ // orig(params)` uniformly without re-checking each tool.
339
+ const orig = tool.execute;
340
+ const origAsync = (params: Record<string, unknown>): Promise<unknown> =>
341
+ Promise.resolve(orig(params));
342
+ tool.execute = (params) => wrapper(origAsync, params);
343
+ }
344
+
53
345
  function overrideVaultInfo(
54
346
  tools: McpToolDef[],
55
347
  vaultName: string,
@@ -64,26 +356,33 @@ function overrideVaultInfo(
64
356
 
65
357
  if (params.description !== undefined) {
66
358
  // Secondary scope check: vault-info is read-gated so read-only callers
67
- // can fetch stats, but mutating the vault description requires write.
68
- // Without this, a vault:read token could bypass the outer gate by
69
- // passing `description` to a tool the outer gate considers read-only.
70
- if (!auth || !hasScope(auth.scopes, SCOPE_WRITE)) {
359
+ // can fetch stats, but mutating the vault description requires write
360
+ // for THIS vault. Without this, a vault:read token could bypass the
361
+ // outer gate by passing `description` to a tool the outer gate
362
+ // considers read-only.
363
+ if (!auth || !hasScopeForVault(auth.scopes, vaultName, "write")) {
71
364
  throw new Error(
72
- `Forbidden: updating the vault description requires the '${SCOPE_WRITE}' scope. Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
365
+ `Forbidden: updating the vault description requires the 'vault:write' scope (or 'vault:${vaultName}:write'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
73
366
  );
74
367
  }
75
368
  config.description = params.description as string;
76
369
  writeVaultConfig(config);
77
370
  }
78
371
 
79
- const result: any = {
372
+ const store = getVaultStore(vaultName);
373
+ const includeStats = Boolean(params.include_stats);
374
+ const projection = buildVaultProjection(store.db, { includeStats });
375
+
376
+ const result: Record<string, unknown> = {
80
377
  name: config.name,
81
378
  description: config.description ?? null,
379
+ tags: projection.tags,
380
+ indexed_fields: projection.indexed_fields,
381
+ query_hints: projection.query_hints,
82
382
  };
83
383
 
84
- if (params.include_stats) {
85
- const store = getVaultStore(vaultName);
86
- result.stats = await store.getVaultStats();
384
+ if (projection.stats) {
385
+ result.stats = projection.stats;
87
386
  }
88
387
 
89
388
  return result;
@@ -84,7 +84,7 @@ export function buildConfigSchema(): ModuleConfigSchema {
84
84
  export function buildConfigValues(
85
85
  vaultConfig: VaultConfig,
86
86
  globalConfig: GlobalConfig,
87
- env: { SCRIBE_URL?: string } = process.env,
87
+ env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
88
88
  ): Record<string, unknown> {
89
89
  return {
90
90
  audio_retention: vaultConfig.audio_retention ?? "keep",