@openparachute/vault 0.4.9-rc.2 → 0.4.9-rc.4
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/core/src/mcp.ts +35 -0
- package/core/src/schema.ts +51 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-routes.test.ts +59 -1
- package/src/mirror-routes.ts +41 -2
- package/src/routing.test.ts +73 -0
- package/src/routing.ts +34 -1
- package/src/token-store.ts +158 -5
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/index-BJX47k5V.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-KA1P2P3z.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/core/src/mcp.ts
CHANGED
|
@@ -20,6 +20,18 @@ export interface McpToolDef {
|
|
|
20
20
|
description: string;
|
|
21
21
|
inputSchema: Record<string, unknown>;
|
|
22
22
|
execute: (params: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Minimum scope verb the caller must hold for THIS vault to see + invoke
|
|
25
|
+
* the tool. `read` for pure queries, `write` for mutations, `admin` for
|
|
26
|
+
* token-management surfaces (only `manage-token` in the current set —
|
|
27
|
+
* core's nine tools cap at `write`). The MCP HTTP layer filters
|
|
28
|
+
* `tools/list` by this field and verb-gates `tools/call` against it; the
|
|
29
|
+
* filter is the primary defense, the inner gate is defense-in-depth.
|
|
30
|
+
*
|
|
31
|
+
* Pre-v19 unstamped tools default to `write` at the dispatch layer so a
|
|
32
|
+
* future addition that forgets to stamp this gets the safer treatment.
|
|
33
|
+
*/
|
|
34
|
+
requiredVerb: "read" | "write" | "admin";
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +114,7 @@ export function generateMcpTools(store: Store): McpToolDef[] {
|
|
|
102
114
|
// =====================================================================
|
|
103
115
|
{
|
|
104
116
|
name: "query-notes",
|
|
117
|
+
requiredVerb: "read",
|
|
105
118
|
description: `Query notes. Returns notes matching the given filters.
|
|
106
119
|
|
|
107
120
|
- **Single note**: pass \`id\` (accepts note ID or path, e.g., "Projects/README")
|
|
@@ -403,6 +416,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
403
416
|
// =====================================================================
|
|
404
417
|
{
|
|
405
418
|
name: "create-note",
|
|
419
|
+
requiredVerb: "write",
|
|
406
420
|
description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.`,
|
|
407
421
|
inputSchema: {
|
|
408
422
|
type: "object",
|
|
@@ -518,6 +532,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
518
532
|
// =====================================================================
|
|
519
533
|
{
|
|
520
534
|
name: "update-note",
|
|
535
|
+
requiredVerb: "write",
|
|
521
536
|
description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
|
|
522
537
|
|
|
523
538
|
- Three content-modification modes (mutually exclusive):
|
|
@@ -930,6 +945,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
930
945
|
// =====================================================================
|
|
931
946
|
{
|
|
932
947
|
name: "delete-note",
|
|
948
|
+
// `write` — same destructive verb as update-note. Aaron's call
|
|
949
|
+
// 2026-05-27: "delete- in write; right now the only admin gated
|
|
950
|
+
// thing is tokens." Reserving `admin` for "operator-only
|
|
951
|
+
// capabilities" (token mgmt + future config writes). A future
|
|
952
|
+
// finer-grained model might split `vault:write:no-delete` for
|
|
953
|
+
// genuinely append-only callers — gating WITHIN write rather
|
|
954
|
+
// than promoting deletes out of it.
|
|
955
|
+
requiredVerb: "write",
|
|
933
956
|
description: "Permanently delete a note and all its tags and links. Accepts ID or path.",
|
|
934
957
|
inputSchema: {
|
|
935
958
|
type: "object",
|
|
@@ -950,6 +973,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
950
973
|
// =====================================================================
|
|
951
974
|
{
|
|
952
975
|
name: "list-tags",
|
|
976
|
+
requiredVerb: "read",
|
|
953
977
|
description: `List tags with usage counts. Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps). Pass \`include_schema: true\` to include the full record for every tag.`,
|
|
954
978
|
inputSchema: {
|
|
955
979
|
type: "object",
|
|
@@ -1004,6 +1028,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1004
1028
|
// =====================================================================
|
|
1005
1029
|
{
|
|
1006
1030
|
name: "update-tag",
|
|
1031
|
+
requiredVerb: "write",
|
|
1007
1032
|
description: "Create or update a tag's identity row: description, indexed-field schemas, typed-link relationships, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
|
|
1008
1033
|
inputSchema: {
|
|
1009
1034
|
type: "object",
|
|
@@ -1159,6 +1184,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1159
1184
|
// =====================================================================
|
|
1160
1185
|
{
|
|
1161
1186
|
name: "delete-tag",
|
|
1187
|
+
// `write` — Aaron's call 2026-05-27: admin reserved for token
|
|
1188
|
+
// mgmt + future config writes; deletes are write-tier mutations.
|
|
1189
|
+
// See delete-note rationale.
|
|
1190
|
+
requiredVerb: "write",
|
|
1162
1191
|
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
|
|
1163
1192
|
inputSchema: {
|
|
1164
1193
|
type: "object",
|
|
@@ -1191,6 +1220,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1191
1220
|
// =====================================================================
|
|
1192
1221
|
{
|
|
1193
1222
|
name: "find-path",
|
|
1223
|
+
requiredVerb: "read",
|
|
1194
1224
|
description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns the chain of note IDs and relationships, or null if no path exists.",
|
|
1195
1225
|
inputSchema: {
|
|
1196
1226
|
type: "object",
|
|
@@ -1215,6 +1245,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1215
1245
|
// =====================================================================
|
|
1216
1246
|
{
|
|
1217
1247
|
name: "vault-info",
|
|
1248
|
+
// `read` so vault:read callers can fetch stats. The
|
|
1249
|
+
// description-update branch performs an inner write-check (see
|
|
1250
|
+
// overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
|
|
1251
|
+
// `write` or read-only callers lose the stats projection.
|
|
1252
|
+
requiredVerb: "read",
|
|
1218
1253
|
description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and query hints. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution. Pass `description` to update the vault description (changes how AI agents behave in future sessions). Call this anytime mid-session to refresh schema context.",
|
|
1219
1254
|
inputSchema: {
|
|
1220
1255
|
type: "object",
|
package/core/src/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 19;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record.
|
|
@@ -118,6 +118,20 @@ CREATE TABLE IF NOT EXISTS indexed_fields (
|
|
|
118
118
|
--
|
|
119
119
|
-- scope_tag / scope_path_prefix are deprecated Phase-0 columns — never
|
|
120
120
|
-- enforced at runtime, kept only for schema stability.
|
|
121
|
+
-- created_via (v19) records the provenance of a token. NULL means the
|
|
122
|
+
-- legacy/unspecified path (CLI, REST mint, YAML import); 'mcp_mint' means
|
|
123
|
+
-- the token was minted by the manage-token MCP tool, which lets the
|
|
124
|
+
-- list/revoke surface of that tool restrict itself to its own session's
|
|
125
|
+
-- mints (no cross-session token management from inside MCP).
|
|
126
|
+
--
|
|
127
|
+
-- parent_jti (v19) is the display id (t_hashprefix) of the token that
|
|
128
|
+
-- minted this one, or the hub-JWT jti claim when minted from a hub
|
|
129
|
+
-- session. Session-pinned list+revoke in manage-token filters on this.
|
|
130
|
+
--
|
|
131
|
+
-- revoked_at (v19) marks soft-revocation. Revoke from manage-token sets
|
|
132
|
+
-- this rather than deleting the row, so the audit trail stays intact and
|
|
133
|
+
-- the second revoke of the same jti is idempotent (returns ok=true).
|
|
134
|
+
-- resolveToken treats a revoked_at-set row as not-found.
|
|
121
135
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
122
136
|
token_hash TEXT PRIMARY KEY,
|
|
123
137
|
label TEXT NOT NULL,
|
|
@@ -129,7 +143,10 @@ CREATE TABLE IF NOT EXISTS tokens (
|
|
|
129
143
|
expires_at TEXT,
|
|
130
144
|
created_at TEXT NOT NULL,
|
|
131
145
|
last_used_at TEXT,
|
|
132
|
-
vault_name TEXT
|
|
146
|
+
vault_name TEXT,
|
|
147
|
+
created_via TEXT,
|
|
148
|
+
parent_jti TEXT,
|
|
149
|
+
revoked_at TEXT
|
|
133
150
|
);
|
|
134
151
|
|
|
135
152
|
-- OAuth: registered clients (Dynamic Client Registration)
|
|
@@ -380,6 +397,12 @@ export function initSchema(db: Database): void {
|
|
|
380
397
|
// (markdown), unchanged in meaning. See vault#328.
|
|
381
398
|
migrateToV18(db);
|
|
382
399
|
|
|
400
|
+
// Migrate v18 → v19: add MCP-mint provenance columns to `tokens`
|
|
401
|
+
// (created_via, parent_jti, revoked_at) for vault#376's manage-token tool.
|
|
402
|
+
// All three are nullable; existing rows backfill to NULL which means
|
|
403
|
+
// "non-MCP-minted, not revoked" — identical pre-v19 semantics.
|
|
404
|
+
migrateToV19(db);
|
|
405
|
+
|
|
383
406
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
384
407
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
385
408
|
rebuildIndexes(db);
|
|
@@ -952,6 +975,32 @@ function migrateToV18(db: Database): void {
|
|
|
952
975
|
}
|
|
953
976
|
}
|
|
954
977
|
|
|
978
|
+
/**
|
|
979
|
+
* Migrate v18 → v19: add `created_via`, `parent_jti`, `revoked_at` columns
|
|
980
|
+
* to `tokens` so manage-token can attribute mints to the MCP session that
|
|
981
|
+
* minted them, scope its list+revoke to those tokens, and soft-revoke
|
|
982
|
+
* (preserving the audit trail).
|
|
983
|
+
*
|
|
984
|
+
* All three columns are nullable; existing rows backfill to NULL with
|
|
985
|
+
* back-compat semantics — `created_via IS NULL` matches the CLI/REST-mint
|
|
986
|
+
* provenance, `revoked_at IS NULL` matches "still active". Idempotent —
|
|
987
|
+
* the column-existence guard means the migration is safe to re-run on a
|
|
988
|
+
* post-v19 vault. See vault#376.
|
|
989
|
+
*/
|
|
990
|
+
function migrateToV19(db: Database): void {
|
|
991
|
+
if (!hasTable(db, "tokens")) return;
|
|
992
|
+
const cols: [string, string][] = [
|
|
993
|
+
["created_via", "TEXT"],
|
|
994
|
+
["parent_jti", "TEXT"],
|
|
995
|
+
["revoked_at", "TEXT"],
|
|
996
|
+
];
|
|
997
|
+
for (const [col, type] of cols) {
|
|
998
|
+
if (!hasColumn(db, "tokens", col)) {
|
|
999
|
+
db.exec(`ALTER TABLE tokens ADD COLUMN ${col} ${type}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
955
1004
|
function hasTable(db: Database, name: string): boolean {
|
|
956
1005
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
957
1006
|
return !!row;
|
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -91,6 +91,12 @@ function tryServerWideAuth(
|
|
|
91
91
|
legacyDerived: false,
|
|
92
92
|
scoped_tags: null,
|
|
93
93
|
vault_name: null,
|
|
94
|
+
// No stable session id for the env-var operator token — every request
|
|
95
|
+
// is the operator-bearer, not a minted session. manage-token's session
|
|
96
|
+
// pin is a no-op for this caller (it'd still mint, but list/revoke
|
|
97
|
+
// would see no other operator mints; that's fine — env-var-bearer is
|
|
98
|
+
// explicitly the operator-channel, not a user surface).
|
|
99
|
+
caller_jti: null,
|
|
94
100
|
};
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -120,6 +126,15 @@ export interface AuthResult {
|
|
|
120
126
|
* legacy / server-wide / hub JWT — no per-vault binding. See vault#257.
|
|
121
127
|
*/
|
|
122
128
|
vault_name: string | null;
|
|
129
|
+
/**
|
|
130
|
+
* Session identifier (v19). For `pvt_*` tokens this is the display id
|
|
131
|
+
* (`t_<hashprefix>`) of the presented token. For hub JWTs it's the
|
|
132
|
+
* `jti` claim, when present. NULL for legacy YAML keys / server-wide
|
|
133
|
+
* env-var tokens / hub JWTs without a `jti`. Used by the manage-token
|
|
134
|
+
* MCP tool to stamp child tokens with `parent_jti` so list/revoke can
|
|
135
|
+
* scope to this session's mints. See vault#376.
|
|
136
|
+
*/
|
|
137
|
+
caller_jti: string | null;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
140
|
/**
|
|
@@ -134,6 +149,7 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
|
|
|
134
149
|
legacyDerived: true,
|
|
135
150
|
scoped_tags: null,
|
|
136
151
|
vault_name: null,
|
|
152
|
+
caller_jti: null,
|
|
137
153
|
};
|
|
138
154
|
}
|
|
139
155
|
|
|
@@ -285,6 +301,7 @@ export async function authenticateVaultRequest(
|
|
|
285
301
|
legacyDerived: resolved.legacyDerived,
|
|
286
302
|
scoped_tags: resolved.scoped_tags,
|
|
287
303
|
vault_name: resolved.vault_name,
|
|
304
|
+
caller_jti: resolved.jti,
|
|
288
305
|
};
|
|
289
306
|
}
|
|
290
307
|
} catch {
|
|
@@ -396,7 +413,17 @@ async function authenticateHubJwt(
|
|
|
396
413
|
hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
|
|
397
414
|
? "full"
|
|
398
415
|
: "read";
|
|
399
|
-
return {
|
|
416
|
+
return {
|
|
417
|
+
permission,
|
|
418
|
+
scopes: claims.scopes,
|
|
419
|
+
legacyDerived: false,
|
|
420
|
+
scoped_tags: null,
|
|
421
|
+
vault_name: null,
|
|
422
|
+
// claims.jti is `undefined` when the issuer didn't stamp one. Pass it
|
|
423
|
+
// through verbatim — manage-token's session-pin will be null in that
|
|
424
|
+
// case, and list/revoke from that session sees no mints.
|
|
425
|
+
caller_jti: claims.jti ?? null,
|
|
426
|
+
};
|
|
400
427
|
} catch (err) {
|
|
401
428
|
if (err instanceof HubJwtError) {
|
|
402
429
|
// Revocation-related codes get sanitized client messages: server-side
|
|
@@ -511,6 +538,7 @@ export async function authenticateGlobalRequest(
|
|
|
511
538
|
legacyDerived: resolved.legacyDerived,
|
|
512
539
|
scoped_tags: resolved.scoped_tags,
|
|
513
540
|
vault_name: resolved.vault_name,
|
|
541
|
+
caller_jti: resolved.jti,
|
|
514
542
|
};
|
|
515
543
|
}
|
|
516
544
|
} catch {
|
package/src/mcp-http.ts
CHANGED
|
@@ -30,29 +30,19 @@ import { hasScopeForVault } from "./scopes.ts";
|
|
|
30
30
|
import type { VaultVerb } from "./scopes.ts";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Required verb for
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* Required verb for an MCP tool. Reads `tool.requiredVerb` from the tool
|
|
34
|
+
* metadata — every core tool stamps this (vault#376) so the filter is data,
|
|
35
|
+
* not a side-table that can drift. The discovery + dispatch paths below
|
|
36
|
+
* call this with the tool object so a future tool that forgets to stamp
|
|
37
|
+
* falls into the default-deny branch.
|
|
38
|
+
*
|
|
39
|
+
* Default-deny: unknown tools require `write`. Keeps accidental reads of
|
|
40
|
+
* a not-yet-mapped mutation tool from slipping past. (`admin` would be
|
|
41
|
+
* safer-still but would refuse vault-info-style read tools to write-scope
|
|
42
|
+
* callers; `write` is the right middle ground.)
|
|
39
43
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
};
|
|
51
|
-
|
|
52
|
-
function requiredVerbForTool(toolName: string): VaultVerb {
|
|
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_VERB[toolName] ?? "write";
|
|
44
|
+
function requiredVerbForTool(tool: { requiredVerb?: VaultVerb }): VaultVerb {
|
|
45
|
+
return tool.requiredVerb ?? "write";
|
|
56
46
|
}
|
|
57
47
|
|
|
58
48
|
/** Handle scoped MCP at /vault/{name}/mcp (single vault). */
|
|
@@ -90,9 +80,12 @@ async function handleMcp(
|
|
|
90
80
|
// Filter the advertised tool list to what the caller's scopes actually
|
|
91
81
|
// permit for THIS vault. Callers without write don't see mutation tools at
|
|
92
82
|
// all — matches the prior behavior of the read/full permission model but
|
|
93
|
-
// now driven by per-vault scope inheritance.
|
|
83
|
+
// now driven by per-vault scope inheritance. With manage-token (vault#376)
|
|
84
|
+
// requiring `admin`, callers without admin don't see it at all — the AI
|
|
85
|
+
// never knows it could mint child tokens, eliminating that escalation
|
|
86
|
+
// vector by listing.
|
|
94
87
|
const visibleTools = mcpTools.filter((t) =>
|
|
95
|
-
hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t
|
|
88
|
+
hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t)),
|
|
96
89
|
);
|
|
97
90
|
|
|
98
91
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -106,18 +99,13 @@ async function handleMcp(
|
|
|
106
99
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
107
100
|
const { name, arguments: args } = request.params;
|
|
108
101
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
isError: true,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const tool = mcpTools.find((t) => t.name === name);
|
|
102
|
+
// Dispatch against the FILTERED tool list — tools the caller can't see
|
|
103
|
+
// in `tools/list` also can't be called explicitly. This matches the
|
|
104
|
+
// user-visible contract: "excluded tools throw 'tool not found' if
|
|
105
|
+
// called explicitly" (vault#376 spec). It also avoids leaking the
|
|
106
|
+
// existence of admin-only tools (manage-token) to write-scope sessions
|
|
107
|
+
// via differential error messages.
|
|
108
|
+
const tool = visibleTools.find((t) => t.name === name);
|
|
121
109
|
if (!tool) {
|
|
122
110
|
return {
|
|
123
111
|
content: [{ type: "text" as const, text: `Unknown tool: ${name}` }],
|
package/src/mcp-tools.ts
CHANGED
|
@@ -14,14 +14,21 @@ import {
|
|
|
14
14
|
} from "../core/src/vault-projection.ts";
|
|
15
15
|
import { readVaultConfig, writeVaultConfig } from "./config.ts";
|
|
16
16
|
import { getVaultStore } from "./vault-store.ts";
|
|
17
|
-
import { hasScopeForVault } from "./scopes.ts";
|
|
17
|
+
import { hasScopeForVault, parseScopes, validateMintedScopes, hasScope, SCOPE_WRITE, SCOPE_ADMIN } from "./scopes.ts";
|
|
18
18
|
import type { AuthResult } from "./auth.ts";
|
|
19
19
|
import {
|
|
20
20
|
expandTokenTagScope,
|
|
21
21
|
noteWithinTagScope,
|
|
22
22
|
tagsWithinScope,
|
|
23
23
|
} from "./tag-scope.ts";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
findTokensReferencingTag,
|
|
26
|
+
generateToken,
|
|
27
|
+
createToken,
|
|
28
|
+
listMcpMintedTokens,
|
|
29
|
+
softRevokeMcpToken,
|
|
30
|
+
type TokenPermission,
|
|
31
|
+
} from "./token-store.ts";
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
34
|
* Filter a vault projection to entries an in-scope tag contributes to.
|
|
@@ -110,6 +117,12 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
|
|
|
110
117
|
applyTagDependencyGuards(tools, vaultName);
|
|
111
118
|
applyTagScopeWrappers(tools, vaultName, auth);
|
|
112
119
|
|
|
120
|
+
// manage-token is server-only (needs token-store + auth context), so it
|
|
121
|
+
// lives here rather than in core. Always appended to the surface; the
|
|
122
|
+
// `requiredVerb: "admin"` filter in mcp-http.ts hides it from non-admin
|
|
123
|
+
// callers. See vault#376.
|
|
124
|
+
tools.push(buildManageTokenTool(vaultName, auth));
|
|
125
|
+
|
|
113
126
|
return tools;
|
|
114
127
|
}
|
|
115
128
|
|
|
@@ -404,3 +417,274 @@ function overrideVaultInfo(
|
|
|
404
417
|
return result;
|
|
405
418
|
};
|
|
406
419
|
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// manage-token (vault#376) — single MCP tool with mint/revoke/list actions
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* TTL bounds for `manage-token` action=mint, in seconds. Short by design:
|
|
427
|
+
* the design doc (vault#376) calls the tool out as the "AI mints a token
|
|
428
|
+
* for one-shot scripted work, then revokes immediately" surface. A long
|
|
429
|
+
* TTL would defeat the safety story — if revoke fails (network blip,
|
|
430
|
+
* model error), the cap is the backstop. Operators wanting long-lived
|
|
431
|
+
* tokens still use the REST /vault/<name>/tokens endpoint.
|
|
432
|
+
*/
|
|
433
|
+
const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
|
|
434
|
+
const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
|
|
435
|
+
|
|
436
|
+
function permissionForScopes(scopes: string[]): TokenPermission {
|
|
437
|
+
return hasScope(scopes, SCOPE_WRITE) || hasScope(scopes, SCOPE_ADMIN) ? "full" : "read";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Build the manage-token MCP tool, wired to the calling session's auth.
|
|
442
|
+
*
|
|
443
|
+
* Closure-captured context:
|
|
444
|
+
* - `vaultName`: every mint pins `vault_name` to this; cross-vault mints
|
|
445
|
+
* are rejected by `validateMintedScopes` (it refuses any
|
|
446
|
+
* `vault:<other>:<verb>` scope).
|
|
447
|
+
* - `auth.scopes`: defense-in-depth subset check on mint. The outer
|
|
448
|
+
* filter already required vault:admin to see the tool, but a hand-
|
|
449
|
+
* crafted JSON-RPC `tools/call` of `manage-token` from a non-admin
|
|
450
|
+
* session would bypass the visibility filter — `validateMintedScopes`
|
|
451
|
+
* plus the `hasScopeForVault(auth.scopes, vaultName, "admin")` guard
|
|
452
|
+
* below catch that case.
|
|
453
|
+
* - `auth.caller_jti`: stamped as `parent_jti` on each mint; list+revoke
|
|
454
|
+
* scope to this jti so each MCP session sees only its own mints.
|
|
455
|
+
* When NULL (legacy / env-var operator / hub JWT without jti), mints
|
|
456
|
+
* still succeed but list/revoke return empty — the operator hits the
|
|
457
|
+
* CLI / REST surface instead for revocation in that path.
|
|
458
|
+
*
|
|
459
|
+
* The execute function is async (token mint touches the store + DB) and
|
|
460
|
+
* returns a discriminated-union response shape: `{action, …}` with `action`
|
|
461
|
+
* matching the requested action. The MCP HTTP layer serializes the result
|
|
462
|
+
* via `JSON.stringify`, so caller-side parsing keys off the action field.
|
|
463
|
+
*/
|
|
464
|
+
function buildManageTokenTool(vaultName: string, auth: AuthResult | undefined): McpToolDef {
|
|
465
|
+
return {
|
|
466
|
+
name: "manage-token",
|
|
467
|
+
requiredVerb: "admin",
|
|
468
|
+
description:
|
|
469
|
+
"Mint, revoke, or list short-TTL vault tokens within this MCP session. " +
|
|
470
|
+
"Designed for one-shot AI-driven workflows: mint a narrow token, run a " +
|
|
471
|
+
"script with it, revoke immediately. Token lifetime defaults to 15 min " +
|
|
472
|
+
"(max 1 hour). Mints are pinned to this vault and to the caller's scope " +
|
|
473
|
+
"subset — you cannot escalate. List + revoke are scoped to tokens this " +
|
|
474
|
+
"session minted; CLI/REST-minted tokens are not surfaced here.\n\n" +
|
|
475
|
+
"Actions (discriminator: `action`):\n" +
|
|
476
|
+
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
|
|
477
|
+
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
|
|
478
|
+
"- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
|
|
479
|
+
inputSchema: {
|
|
480
|
+
type: "object",
|
|
481
|
+
properties: {
|
|
482
|
+
action: {
|
|
483
|
+
type: "string",
|
|
484
|
+
enum: ["mint", "revoke", "list"],
|
|
485
|
+
description: "Which action to perform. Required.",
|
|
486
|
+
},
|
|
487
|
+
scope: {
|
|
488
|
+
oneOf: [
|
|
489
|
+
{ type: "string" },
|
|
490
|
+
{ type: "array", items: { type: "string" } },
|
|
491
|
+
],
|
|
492
|
+
description:
|
|
493
|
+
"(action=mint) Scope to grant. String like \"vault:write\" or array. Must be a subset of the caller's scope; cross-vault scopes are rejected.",
|
|
494
|
+
},
|
|
495
|
+
ttl_seconds: {
|
|
496
|
+
type: "number",
|
|
497
|
+
description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour). Values outside (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}] are rejected.`,
|
|
498
|
+
},
|
|
499
|
+
description: {
|
|
500
|
+
type: "string",
|
|
501
|
+
description: "(action=mint, optional) Free-text label surfaced in the token list + audit trail.",
|
|
502
|
+
},
|
|
503
|
+
jti: {
|
|
504
|
+
type: "string",
|
|
505
|
+
description: "(action=revoke) The jti (e.g. `t_abc123…`) returned by a prior mint. Revoke is idempotent — second revoke also returns ok=true.",
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
required: ["action"],
|
|
509
|
+
},
|
|
510
|
+
execute: async (params) => {
|
|
511
|
+
const action = params.action;
|
|
512
|
+
|
|
513
|
+
// Defense-in-depth: the outer filter (mcp-http.ts visibleTools)
|
|
514
|
+
// already requires vault:admin for this vault to see manage-token,
|
|
515
|
+
// so reaching execute means the gate passed. A hand-crafted
|
|
516
|
+
// tools/call bypassing list would still hit the dispatch verb-check
|
|
517
|
+
// in handleScopedMcp. The block below is a third belt-and-suspenders
|
|
518
|
+
// check so a refactor of either layer can't lose the invariant
|
|
519
|
+
// silently.
|
|
520
|
+
if (!auth || !hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
521
|
+
return {
|
|
522
|
+
action,
|
|
523
|
+
error: "Forbidden",
|
|
524
|
+
message: `manage-token requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted: ${auth?.scopes.join(" ") || "(none)"}.`,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (action === "mint") return await mintAction(params, vaultName, auth);
|
|
529
|
+
if (action === "revoke") return revokeAction(params, vaultName, auth);
|
|
530
|
+
if (action === "list") return listAction(vaultName, auth);
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
error: "invalid_request",
|
|
534
|
+
message: `manage-token: unknown action "${String(action)}" — expected "mint" | "revoke" | "list".`,
|
|
535
|
+
};
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function mintAction(
|
|
541
|
+
params: Record<string, unknown>,
|
|
542
|
+
vaultName: string,
|
|
543
|
+
auth: AuthResult,
|
|
544
|
+
): Promise<Record<string, unknown>> {
|
|
545
|
+
// Scope parsing: accept string or string[]. Empty/missing is rejected
|
|
546
|
+
// explicitly (no implicit "full scope" default — manage-token always
|
|
547
|
+
// narrows). The validateMintedScopes call then enforces:
|
|
548
|
+
// - shape (recognized vault scope)
|
|
549
|
+
// - vault-pin (cross-vault rejected)
|
|
550
|
+
// - subset of caller's scope on this vault.
|
|
551
|
+
let requested: string[];
|
|
552
|
+
if (typeof params.scope === "string") {
|
|
553
|
+
requested = parseScopes(params.scope);
|
|
554
|
+
} else if (Array.isArray(params.scope)) {
|
|
555
|
+
requested = params.scope.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
556
|
+
} else {
|
|
557
|
+
return {
|
|
558
|
+
action: "mint",
|
|
559
|
+
error: "invalid_request",
|
|
560
|
+
message: "manage-token mint: `scope` is required (string or string[]).",
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
if (requested.length === 0) {
|
|
564
|
+
return {
|
|
565
|
+
action: "mint",
|
|
566
|
+
error: "invalid_request",
|
|
567
|
+
message: "manage-token mint: at least one scope required.",
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const validation = validateMintedScopes(requested, vaultName, auth.scopes);
|
|
572
|
+
if (!validation.ok) {
|
|
573
|
+
return {
|
|
574
|
+
action: "mint",
|
|
575
|
+
error: "forbidden",
|
|
576
|
+
message: "manage-token mint: scope rejected (must be a subset of the caller's scope on this vault).",
|
|
577
|
+
rejected: validation.rejected,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// TTL bounds. Default 900 (15 min); explicit values must satisfy
|
|
582
|
+
// `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`. Zero, negative, NaN, and
|
|
583
|
+
// beyond-max all reject — the cap is the safety backstop if revoke fails,
|
|
584
|
+
// so it must be strict.
|
|
585
|
+
let ttl = MANAGE_TOKEN_DEFAULT_TTL_SECONDS;
|
|
586
|
+
if (params.ttl_seconds !== undefined && params.ttl_seconds !== null) {
|
|
587
|
+
if (typeof params.ttl_seconds !== "number" || !Number.isFinite(params.ttl_seconds)) {
|
|
588
|
+
return {
|
|
589
|
+
action: "mint",
|
|
590
|
+
error: "invalid_request",
|
|
591
|
+
message: "manage-token mint: ttl_seconds must be a finite number.",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (params.ttl_seconds <= 0 || params.ttl_seconds > MANAGE_TOKEN_MAX_TTL_SECONDS) {
|
|
595
|
+
return {
|
|
596
|
+
action: "mint",
|
|
597
|
+
error: "invalid_request",
|
|
598
|
+
message: `manage-token mint: ttl_seconds must be in (0, ${MANAGE_TOKEN_MAX_TTL_SECONDS}]; got ${params.ttl_seconds}.`,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
ttl = params.ttl_seconds;
|
|
602
|
+
}
|
|
603
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
604
|
+
|
|
605
|
+
const description = typeof params.description === "string" && params.description.length > 0
|
|
606
|
+
? params.description
|
|
607
|
+
: null;
|
|
608
|
+
const label = description ?? `mcp-mint (parent=${auth.caller_jti ?? "unknown"})`;
|
|
609
|
+
|
|
610
|
+
const store = getVaultStore(vaultName);
|
|
611
|
+
const { fullToken } = generateToken();
|
|
612
|
+
const created = createToken(store.db, fullToken, {
|
|
613
|
+
label,
|
|
614
|
+
permission: permissionForScopes(requested),
|
|
615
|
+
scopes: requested,
|
|
616
|
+
// Tag scoping: inherit the caller's allowlist verbatim. We don't expose
|
|
617
|
+
// a `tags` param on manage-token yet — the design doc keeps the v1
|
|
618
|
+
// surface minimal. When the caller is tag-scoped, the minted token
|
|
619
|
+
// carries the same allowlist (no narrowing, no widening); when the
|
|
620
|
+
// caller is unscoped, the mint is unscoped. Future widening of the
|
|
621
|
+
// surface should re-use tokens-routes.ts' validation path so the rules
|
|
622
|
+
// stay in lockstep.
|
|
623
|
+
scoped_tags: auth.scoped_tags,
|
|
624
|
+
vault_name: vaultName,
|
|
625
|
+
expires_at: expiresAt,
|
|
626
|
+
created_via: "mcp_mint",
|
|
627
|
+
parent_jti: auth.caller_jti,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
action: "mint",
|
|
632
|
+
token: fullToken,
|
|
633
|
+
jti: `t_${created.token_hash.slice(7, 19)}`,
|
|
634
|
+
expires_at: expiresAt,
|
|
635
|
+
scopes: requested,
|
|
636
|
+
scoped_tags: auth.scoped_tags,
|
|
637
|
+
vault_name: vaultName,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function revokeAction(
|
|
642
|
+
params: Record<string, unknown>,
|
|
643
|
+
vaultName: string,
|
|
644
|
+
auth: AuthResult,
|
|
645
|
+
): Record<string, unknown> {
|
|
646
|
+
if (typeof params.jti !== "string" || params.jti.length === 0) {
|
|
647
|
+
return {
|
|
648
|
+
action: "revoke",
|
|
649
|
+
ok: false,
|
|
650
|
+
error: "invalid_request",
|
|
651
|
+
message: "manage-token revoke: `jti` is required (string).",
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
// Session-pin: revoke is restricted to tokens this MCP session minted.
|
|
655
|
+
// When auth.caller_jti is null (no stable session id — env-var operator,
|
|
656
|
+
// legacy YAML key, hub JWT without jti), there are no MCP-minted tokens
|
|
657
|
+
// attributable to this session, so revoke returns not_found.
|
|
658
|
+
if (!auth.caller_jti) {
|
|
659
|
+
return {
|
|
660
|
+
action: "revoke",
|
|
661
|
+
ok: false,
|
|
662
|
+
error: "not_found",
|
|
663
|
+
message: "manage-token revoke: this session has no stable id; revoke via the CLI or REST surface.",
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const store = getVaultStore(vaultName);
|
|
667
|
+
const result = softRevokeMcpToken(store.db, params.jti, auth.caller_jti, vaultName);
|
|
668
|
+
if (!result.ok) {
|
|
669
|
+
// Idempotency: not-found returns ok=true so the AI's "mint → run →
|
|
670
|
+
// revoke" loop doesn't surface a confusing failure when a network
|
|
671
|
+
// blip causes a duplicate revoke call. The spec calls this out
|
|
672
|
+
// explicitly (vault#376). The "already minted by another session"
|
|
673
|
+
// case also lands here; we don't differentiate (no information leak
|
|
674
|
+
// about other sessions' jti space).
|
|
675
|
+
return { action: "revoke", ok: true, note: "no matching token in this session" };
|
|
676
|
+
}
|
|
677
|
+
return { action: "revoke", ok: true, already_revoked: result.already_revoked };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function listAction(vaultName: string, auth: AuthResult): Record<string, unknown> {
|
|
681
|
+
if (!auth.caller_jti) {
|
|
682
|
+
// No session id → no attributable mints. Return empty list rather
|
|
683
|
+
// than erroring, so callers can branch on tokens.length without
|
|
684
|
+
// exception handling.
|
|
685
|
+
return { action: "list", tokens: [] };
|
|
686
|
+
}
|
|
687
|
+
const store = getVaultStore(vaultName);
|
|
688
|
+
const tokens = listMcpMintedTokens(store.db, auth.caller_jti, vaultName);
|
|
689
|
+
return { action: "list", tokens };
|
|
690
|
+
}
|