@openparachute/vault 0.4.0 → 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.
package/src/mcp-tools.ts CHANGED
@@ -7,6 +7,11 @@
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
17
  import { hasScopeForVault } from "./scopes.ts";
@@ -18,22 +23,72 @@ import {
18
23
  } from "./tag-scope.ts";
19
24
  import { findTokensReferencingTag } from "./token-store.ts";
20
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
+ }
53
+
21
54
  /**
22
55
  * Get the MCP server instruction for a vault.
23
- * 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.
24
73
  */
25
- export function getServerInstruction(vaultName: string): string {
74
+ export async function getServerInstruction(
75
+ vaultName: string,
76
+ auth?: AuthResult,
77
+ ): Promise<string> {
26
78
  const config = readVaultConfig(vaultName);
79
+ const store = getVaultStore(vaultName);
80
+ let projection = buildVaultProjection(store.db, { includeStats: true });
27
81
 
28
- const parts: string[] = [
29
- `You are connected to Parachute Vault "${vaultName}".`,
30
- ];
31
-
32
- if (config?.description) {
33
- 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);
34
85
  }
35
86
 
36
- return parts.join("\n");
87
+ return projectionToMarkdown({
88
+ vaultName,
89
+ description: config?.description ?? null,
90
+ projection,
91
+ });
37
92
  }
38
93
 
39
94
  /**
@@ -92,10 +147,11 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
92
147
  * unscoped sessions retain identical pre-tag-scope behavior.
93
148
  *
94
149
  * Read tools handled here:
95
- * - query-notes: filter single-note returns + result lists
96
- * - list-tags: filter to allowlisted tags + descendants
97
- * - find-path: require both endpoints (and every hop) in scope
98
- * - synthesize-notes: anchor + neighbors all gated by scope
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
99
155
  *
100
156
  * Write-tool gating happens in handleScopedMcp at the verb-scope layer
101
157
  * AND inside each tool's wrapper here (so a tag-scoped `vault:write`
@@ -155,28 +211,30 @@ function applyTagScopeWrappers(
155
211
  return result;
156
212
  });
157
213
 
158
- wrapReadTool(tools, "synthesize-notes", async (orig, params) => {
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) => {
159
225
  const allowed = await getAllowed();
160
- if (!allowed) return await orig(params);
161
- // Verify the anchor is in scope first — out-of-scope anchor 404s as if
162
- // the note doesn't exist, mirroring the REST find-path semantics.
163
- const anchorId = (params as any).id ?? (params as any).note_id;
164
- if (anchorId) {
165
- const anchor = await store.getNote(anchorId as string);
166
- if (!anchor || !noteWithinTagScope(anchor, allowed, rawTags)) {
167
- return { error: "Note not found", id: anchorId };
168
- }
169
- }
170
226
  const result = await orig(params);
171
- // Filter neighbors to those in scope. The synthesize-notes shape exposes
172
- // `neighbors` (array of note objects with tags) — mirror the query-notes
173
- // filter pattern here.
174
- if (result && typeof result === "object" && Array.isArray((result as any).neighbors)) {
175
- (result as any).neighbors = (result as any).neighbors.filter((n: any) =>
176
- noteWithinTagScope(n, allowed, rawTags),
177
- );
178
- }
179
- return result;
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;
180
238
  });
181
239
 
182
240
  // ---- Write-side guards ----
@@ -266,59 +324,6 @@ function applyTagScopeWrappers(
266
324
  return await orig(params);
267
325
  });
268
326
 
269
- // Note-schemas mappings — same auth boundary as REST `handleNoteSchemas`.
270
- // `tag`-kind mappings are tag-scoped data; `path_prefix`-kind mappings carry
271
- // no tag-axis information and stay visible/writable. The single-tag check
272
- // delegates to `tagsWithinScope` so the string-form fallback is honored.
273
-
274
- wrapReadTool(tools, "list-note-schemas", async (orig, params) => {
275
- const allowed = await getAllowed();
276
- if (!allowed) return await orig(params);
277
- const result = await orig(params);
278
- const filterMappings = (mappings: any[]): any[] =>
279
- mappings.filter(
280
- (m: any) => m.match_kind !== "tag" || tagsWithinScope([m.match_value], allowed, rawTags),
281
- );
282
- if (Array.isArray(result)) {
283
- return result.map((s: any) =>
284
- Array.isArray(s.mappings) ? { ...s, mappings: filterMappings(s.mappings) } : s,
285
- );
286
- }
287
- if (result && typeof result === "object" && Array.isArray((result as any).mappings)) {
288
- return { ...(result as any), mappings: filterMappings((result as any).mappings) };
289
- }
290
- return result;
291
- });
292
-
293
- wrapReadTool(tools, "set-schema-mapping", async (orig, params) => {
294
- const allowed = await getAllowed();
295
- if (!allowed) return await orig(params);
296
- const match_kind = (params as any).match_kind;
297
- const match_value = (params as any).match_value;
298
- if (
299
- match_kind === "tag" &&
300
- typeof match_value === "string" &&
301
- !tagsWithinScope([match_value], allowed, rawTags)
302
- ) {
303
- return forbidden(`set-schema-mapping: tag "${match_value}" is outside the token's allowlist`);
304
- }
305
- return await orig(params);
306
- });
307
-
308
- wrapReadTool(tools, "delete-schema-mapping", async (orig, params) => {
309
- const allowed = await getAllowed();
310
- if (!allowed) return await orig(params);
311
- const match_kind = (params as any).match_kind;
312
- const match_value = (params as any).match_value;
313
- if (
314
- match_kind === "tag" &&
315
- typeof match_value === "string" &&
316
- !tagsWithinScope([match_value], allowed, rawTags)
317
- ) {
318
- return forbidden(`delete-schema-mapping: tag "${match_value}" is outside the token's allowlist`);
319
- }
320
- return await orig(params);
321
- });
322
327
  }
323
328
 
324
329
  function wrapReadTool(
@@ -364,14 +369,20 @@ function overrideVaultInfo(
364
369
  writeVaultConfig(config);
365
370
  }
366
371
 
367
- 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> = {
368
377
  name: config.name,
369
378
  description: config.description ?? null,
379
+ tags: projection.tags,
380
+ indexed_fields: projection.indexed_fields,
381
+ query_hints: projection.query_hints,
370
382
  };
371
383
 
372
- if (params.include_stats) {
373
- const store = getVaultStore(vaultName);
374
- result.stats = await store.getVaultStats();
384
+ if (projection.stats) {
385
+ result.stats = projection.stats;
375
386
  }
376
387
 
377
388
  return result;