@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.9

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-http.ts CHANGED
@@ -45,6 +45,48 @@ function requiredVerbForTool(tool: { requiredVerb?: VaultVerb }): VaultVerb {
45
45
  return tool.requiredVerb ?? "write";
46
46
  }
47
47
 
48
+ /** Matches the JSON-RPC prefix the MCP SDK's `McpError` constructor bakes
49
+ * into `.message` itself (not just `.toString()`) — e.g.
50
+ * `"MCP error -32602: the actual message"`. */
51
+ const MCP_ERROR_PREFIX_RE = /^MCP error -?\d+:\s*/;
52
+
53
+ /**
54
+ * Build a JSON-RPC `McpError` for a domain error, in ONE place (vault#555
55
+ * fix 6 — was 15 duplicated `new McpError(...)` call sites below, each a
56
+ * chance to drift). Two things this fixes over the old inline construction:
57
+ *
58
+ * 1. **No double-wrap.** The MCP SDK's `McpError` constructor bakes
59
+ * `"MCP error <code>: "` into `.message` ITSELF. If the message being
60
+ * wrapped ALREADY carries that prefix (a message forwarded verbatim
61
+ * from another MCP-speaking service, or a stale caller that read
62
+ * `err.message` off an already-formed `McpError` instead of re-throwing
63
+ * it — see the `err instanceof McpError` guard at the top of the catch
64
+ * block below, which is the PRIMARY fix for that exact case), naively
65
+ * constructing `new McpError(code, message, data)` doubles it:
66
+ * `"MCP error -32602: MCP error -32602: ..."`. This strips any
67
+ * pre-existing prefix before building the new one, so the message is
68
+ * single-prefixed no matter how many times a message string passes
69
+ * through this function.
70
+ * 2. **`error_type` in the human-readable message too** (optional polish,
71
+ * vault#555) — a string-reading human (not just a JSON-parsing agent
72
+ * keying off `data.error_type`) sees `[error_type] message` rather than
73
+ * a bare message with no clue which structured category it belongs to.
74
+ *
75
+ * `data.error_type` fidelity was never actually broken — it was always
76
+ * threaded correctly through the JSON-RPC `data` field; only the
77
+ * human-readable `message` string could double-prefix.
78
+ */
79
+ export function mcpDomainError(
80
+ code: ErrorCode,
81
+ rawMessage: string,
82
+ data: Record<string, unknown>,
83
+ ): McpError {
84
+ const cleanMessage = rawMessage.replace(MCP_ERROR_PREFIX_RE, "");
85
+ const errorType = typeof data.error_type === "string" ? data.error_type : undefined;
86
+ const humanMessage = errorType ? `[${errorType}] ${cleanMessage}` : cleanMessage;
87
+ return new McpError(code, humanMessage, data);
88
+ }
89
+
48
90
  /**
49
91
  * Handle scoped MCP at /vault/{name}/mcp (single vault).
50
92
  *
@@ -75,7 +117,10 @@ export async function handleScopedMcp(
75
117
  );
76
118
  }
77
119
 
78
- async function handleMcp(
120
+ /** Exported for direct testing (vault#555 fix 6) — lets a test inject a
121
+ * synthetic failing tool to exercise the `err instanceof McpError` guard
122
+ * without needing a real domain error class to construct one naturally. */
123
+ export async function handleMcp(
79
124
  req: Request,
80
125
  getTools: () => McpToolDef[],
81
126
  serverName: string,
@@ -139,6 +184,18 @@ async function handleMcp(
139
184
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
140
185
  };
141
186
  } catch (err) {
187
+ // vault#555 fix 6 — never re-wrap an already-formed McpError. Passing
188
+ // the SAME instance straight through is strictly correct (no
189
+ // information lost — `.message` AND `.data.error_type` are already
190
+ // exactly right) and is the primary fix for the double-prefix bug
191
+ // ("MCP error -32602: MCP error -32602: ..."): every branch below
192
+ // reads `err.message` and feeds it into a NEW McpError, which would
193
+ // double the SDK's baked-in "MCP error <code>: " prefix if `err` were
194
+ // already one. See `mcpDomainError`'s doc comment for the belt-and-
195
+ // suspenders half of this fix (stripping a pre-existing prefix from a
196
+ // plain message too).
197
+ if (err instanceof McpError) throw err;
198
+
142
199
  // Domain errors from the core tools (conflict, missing precondition,
143
200
  // path collisions, batch caps, cursor/query validation, tag-field
144
201
  // conflicts, ...) get surfaced as JSON-RPC errors with a structured
@@ -182,7 +239,7 @@ async function handleMcp(
182
239
  // existing unstructured `isError: true` fallback — only the NEW #550
183
240
  // call sites that explicitly set `error_type` opt into this shape.
184
241
  if (e?.error_type === "invalid_query") {
185
- throw new McpError(ErrorCode.InvalidParams, message, {
242
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
186
243
  error_type: "invalid_query",
187
244
  field: e.field,
188
245
  got: e.got,
@@ -195,7 +252,7 @@ async function handleMcp(
195
252
  // caller can tell "your search_mode:\"advanced\" syntax is malformed"
196
253
  // apart from "your query PARAMS are malformed."
197
254
  if (e?.error_type === "invalid_search_syntax") {
198
- throw new McpError(ErrorCode.InvalidParams, message, {
255
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
199
256
  error_type: "invalid_search_syntax",
200
257
  field: e.field,
201
258
  got: e.got,
@@ -203,7 +260,7 @@ async function handleMcp(
203
260
  });
204
261
  }
205
262
  if (e?.code === "CONFLICT") {
206
- throw new McpError(ErrorCode.InvalidRequest, message, {
263
+ throw mcpDomainError(ErrorCode.InvalidRequest, message, {
207
264
  error_type: "conflict",
208
265
  current_updated_at: e.current_updated_at ?? null,
209
266
  your_updated_at: e.expected_updated_at,
@@ -216,7 +273,7 @@ async function handleMcp(
216
273
  // DISTINCT vocabulary from `conflict` (settled lead #3): the value
217
274
  // didn't match, not the updated_at token.
218
275
  if (e?.code === "TRANSITION_CONFLICT") {
219
- throw new McpError(ErrorCode.InvalidRequest, message, {
276
+ throw mcpDomainError(ErrorCode.InvalidRequest, message, {
220
277
  error_type: "transition_conflict",
221
278
  note_id: e.note_id,
222
279
  path: e.note_path ?? null,
@@ -230,14 +287,14 @@ async function handleMcp(
230
287
  // Strict-schema rejection (vault#299 Part A) — one error carrying ALL
231
288
  // per-field violations (settled lead #1).
232
289
  if (e?.code === "SCHEMA_VALIDATION") {
233
- throw new McpError(ErrorCode.InvalidParams, message, {
290
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
234
291
  error_type: "schema_validation",
235
292
  violations: e.violations ?? [],
236
293
  hint: "fix every field listed in violations and retry — none of this write was applied",
237
294
  });
238
295
  }
239
296
  if (e?.code === "PRECONDITION_REQUIRED") {
240
- throw new McpError(ErrorCode.InvalidParams, message, {
297
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
241
298
  error_type: "precondition_required",
242
299
  note_id: e.note_id,
243
300
  path: e.note_path ?? null,
@@ -252,7 +309,7 @@ async function handleMcp(
252
309
  // REST already uses for this whole error class — so these no longer
253
310
  // fall through to the unstructured `isError: true` text.
254
311
  if (e?.name === "QueryError") {
255
- throw new McpError(ErrorCode.InvalidParams, message, {
312
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
256
313
  error_type: e.error_type ?? "invalid_query",
257
314
  code: e.code,
258
315
  field: e.field,
@@ -266,14 +323,14 @@ async function handleMcp(
266
323
  // already the exact `error_type` vocabulary: "cursor_invalid" or
267
324
  // "cursor_query_mismatch".
268
325
  if (e?.name === "CursorError" && typeof e.code === "string") {
269
- throw new McpError(ErrorCode.InvalidParams, message, {
326
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
270
327
  error_type: e.code,
271
328
  });
272
329
  }
273
330
  // Path-rename/create collision (vault#126, vault#554) — schema's
274
331
  // UNIQUE(path) tripped. Mirrors REST's 409 `path_conflict` shape.
275
332
  if (e?.code === "PATH_CONFLICT") {
276
- throw new McpError(ErrorCode.InvalidRequest, message, {
333
+ throw mcpDomainError(ErrorCode.InvalidRequest, message, {
277
334
  error_type: "path_conflict",
278
335
  path: e.path,
279
336
  });
@@ -282,7 +339,7 @@ async function handleMcp(
282
339
  // mirrors REST's 409 `ambiguous_path` shape (candidates = the
283
340
  // disambiguating extensions).
284
341
  if (e?.code === "AMBIGUOUS_PATH") {
285
- throw new McpError(ErrorCode.InvalidRequest, message, {
342
+ throw mcpDomainError(ErrorCode.InvalidRequest, message, {
286
343
  error_type: "ambiguous_path",
287
344
  path: e.path,
288
345
  candidates: e.candidates,
@@ -291,7 +348,7 @@ async function handleMcp(
291
348
  // Bad `extension` value (vault#328, vault#554) — mirrors REST's 400
292
349
  // `invalid_extension` shape.
293
350
  if (e?.code === "INVALID_EXTENSION") {
294
- throw new McpError(ErrorCode.InvalidParams, message, {
351
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
295
352
  error_type: "invalid_extension",
296
353
  extension: e.extension,
297
354
  reason: e.reason,
@@ -300,7 +357,7 @@ async function handleMcp(
300
357
  // Batch cap exceeded (#213, vault#554) — mirrors REST's 413
301
358
  // `batch_too_large` shape.
302
359
  if (e?.code === "BATCH_TOO_LARGE") {
303
- throw new McpError(ErrorCode.InvalidRequest, message, {
360
+ throw mcpDomainError(ErrorCode.InvalidRequest, message, {
304
361
  error_type: "batch_too_large",
305
362
  limit: e.limit,
306
363
  got: e.got,
@@ -309,7 +366,7 @@ async function handleMcp(
309
366
  // update-tag cross-tag field conflicts (vault#553/#554) — carries
310
367
  // EVERY violation in one response (see `TagFieldConflictError`).
311
368
  if (e?.code === "TAG_FIELD_CONFLICT") {
312
- throw new McpError(ErrorCode.InvalidParams, message, {
369
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
313
370
  error_type: "tag_field_conflict",
314
371
  tag: e.tag,
315
372
  violations: e.violations ?? [],
@@ -320,7 +377,7 @@ async function handleMcp(
320
377
  // wrapper (src/mcp-tools.ts) scope-scrubs `cycle` before this throw
321
378
  // for a tag-scoped caller, same as TAG_FIELD_CONFLICT above.
322
379
  if (e?.code === "PARENT_CYCLE") {
323
- throw new McpError(ErrorCode.InvalidRequest, message, {
380
+ throw mcpDomainError(ErrorCode.InvalidRequest, message, {
324
381
  error_type: "parent_cycle",
325
382
  tag: e.tag,
326
383
  cycle: e.cycle ?? [],
@@ -338,7 +395,7 @@ async function handleMcp(
338
395
  // error" true by construction: only errors nobody tagged with
339
396
  // `error_type` still hit the fallback below.
340
397
  if (typeof e?.error_type === "string") {
341
- throw new McpError(ErrorCode.InvalidParams, message, {
398
+ throw mcpDomainError(ErrorCode.InvalidParams, message, {
342
399
  error_type: e.error_type,
343
400
  field: e.field,
344
401
  hint: e.hint,
package/src/mcp-tools.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  scrubParentCycleError,
27
27
  scrubReferencingTagsByScope,
28
28
  scrubTagFieldViolationsByScope,
29
+ scrubValidationStatusByScope,
29
30
  tagsWithinScope,
30
31
  } from "./tag-scope.ts";
31
32
  import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
@@ -173,6 +174,20 @@ export function generateScopedMcpTools(
173
174
  )
174
175
  : undefined;
175
176
 
177
+ // Tag-scope guard for `create-note` `if_exists` (vault#555 auth-review
178
+ // must-fix): the core `if_exists` upsert resolves the target path VAULT-WIDE
179
+ // and returns/updates/replaces the found note, so a scoped caller could
180
+ // read/overwrite an out-of-scope note by naming its path. Inject a
181
+ // visibility predicate the core `applyExistingNote` consults on the RESOLVED
182
+ // existing note — covering BOTH the proactive check AND the concurrent-INSERT
183
+ // race backstop (a wrapper-only pre-check misses the latter). Reads from the
184
+ // SAME shared `allowedHolder` the create-note wrapper's `getAllowed()`
185
+ // populates before core's execute runs. Unscoped sessions install no
186
+ // predicate (unchanged). Same closure as `expandVisibility`.
187
+ const ifExistsVisible = scoped
188
+ ? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
189
+ : undefined;
190
+
176
191
  // Write-attribution (vault#298). Every write through an MCP session arrives
177
192
  // on the `mcp` channel — so we REFINE the auth's base `via` (the generic
178
193
  // credential class) to `mcp` here, where the path/channel is known. The
@@ -196,10 +211,11 @@ export function generateScopedMcpTools(
196
211
 
197
212
  const tools = generateMcpTools(
198
213
  store,
199
- expandVisibility || nearTraversable || writeContext || strictBypass
214
+ expandVisibility || nearTraversable || ifExistsVisible || writeContext || strictBypass
200
215
  ? {
201
216
  ...(expandVisibility ? { expandVisibility } : {}),
202
217
  ...(nearTraversable ? { nearTraversable } : {}),
218
+ ...(ifExistsVisible ? { ifExistsVisible } : {}),
203
219
  ...(writeContext ? { writeContext } : {}),
204
220
  ...(strictBypass ? { strictBypass } : {}),
205
221
  ...(onStrictBypass ? { onStrictBypass } : {}),
@@ -332,6 +348,16 @@ function applyTagScopeWrappers(
332
348
  if (n && Array.isArray(n.links)) {
333
349
  n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
334
350
  }
351
+ // vault#555 auth review — a note the caller can see may ALSO carry an
352
+ // out-of-scope co-tag whose schema `validation_status` would otherwise
353
+ // leak (field name / type / enum values, the #560 class). Scrub it with
354
+ // the same allowlist the link scrub uses. Reads the resolved holder for
355
+ // the same reason (see the ordering-invariant note above scrubNoteLinks).
356
+ if (n && n.validation_status) {
357
+ const scrubbed = scrubValidationStatusByScope(n.validation_status, allowedHolder?.value ?? null, rawTags);
358
+ if (scrubbed === undefined) delete n.validation_status;
359
+ else n.validation_status = scrubbed;
360
+ }
335
361
  return n;
336
362
  };
337
363
 
@@ -487,6 +513,13 @@ function applyTagScopeWrappers(
487
513
  return forbidden("create-note: every note must carry at least one tag in the token's allowlist");
488
514
  }
489
515
  }
516
+ // `if_exists` scope enforcement (vault#555 auth-review must-fix) is NOT
517
+ // done here as a wrapper pre-check — that would miss core's concurrent-
518
+ // INSERT race backstop. Instead the `ifExistsVisible` predicate wired into
519
+ // generateMcpTools above fires INSIDE core's `applyExistingNote`, covering
520
+ // both the proactive site and the race-backstop site with one guard. The
521
+ // `await getAllowed()` at the top of this wrapper populates the shared
522
+ // `allowedHolder` the predicate reads, before core's execute runs.
490
523
  return await orig(params);
491
524
  });
492
525
 
@@ -814,8 +847,8 @@ function buildManageTokenTool(
814
847
  ":admin'. List + revoke are scoped to tokens this session minted; " +
815
848
  "CLI/REST-minted tokens are not surfaced here.\n\n" +
816
849
  "Actions (discriminator: `action`):\n" +
817
- "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
818
- "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }\n" +
850
+ "- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
851
+ "- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean, already_revoked?: boolean } — idempotent; a jti not in this session's ledger, or already revoked, still returns ok:true. A genuine failure additionally carries error/message (and, for a hub-side rejection, hub_status).\n" +
819
852
  "- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
820
853
  inputSchema: {
821
854
  type: "object",