@openparachute/vault 0.4.0 → 0.4.4-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/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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:
|
|
96
|
-
* - list-tags:
|
|
97
|
-
* - find-path:
|
|
98
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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 (
|
|
373
|
-
|
|
374
|
-
result.stats = await store.getVaultStats();
|
|
384
|
+
if (projection.stats) {
|
|
385
|
+
result.stats = projection.stats;
|
|
375
386
|
}
|
|
376
387
|
|
|
377
388
|
return result;
|