@openparachute/vault 0.4.9-rc.3 → 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 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",
@@ -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 = 18;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.9-rc.3",
3
+ "version": "0.4.9-rc.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
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 { permission, scopes: claims.scopes, legacyDerived: false, scoped_tags: null, vault_name: null };
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 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.
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
- 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
- };
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.name)),
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
- const neededVerb = requiredVerbForTool(name);
110
- if (!hasScopeForVault(auth.scopes, vaultName, neededVerb)) {
111
- return {
112
- content: [{
113
- type: "text" as const,
114
- text: `Forbidden: tool '${name}' requires the 'vault:${neededVerb}' scope (or 'vault:${vaultName}:${neededVerb}'). Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
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 { findTokensReferencingTag } from "./token-store.ts";
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
+ }