@openparachute/vault 0.7.3-rc.6 → 0.7.3-rc.7

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
@@ -45,6 +45,18 @@ import {
45
45
  contentRangeRequiresContent,
46
46
  MIN_CONTENT_LENGTH,
47
47
  } from "./content-range.js";
48
+ import {
49
+ BLOCKED_ATTACHMENT_EXTENSIONS,
50
+ sanitizeAttachmentExtension,
51
+ mimeForAttachmentExtension,
52
+ } from "./attachment/policy.js";
53
+ import {
54
+ computeTicketTtlMs,
55
+ generateTicketId,
56
+ MAX_TICKET_UPLOAD_BYTES,
57
+ type AttachmentTicket,
58
+ type AttachmentTicketProvider,
59
+ } from "./attachment/tickets.js";
48
60
 
49
61
  export interface McpToolDef {
50
62
  name: string;
@@ -80,7 +92,7 @@ export interface McpToolDef {
80
92
  */
81
93
  function structuredError(
82
94
  message: string,
83
- fields: { error_type: string; field?: string; hint?: string },
95
+ fields: { error_type: string; field?: string; hint?: string } & Record<string, unknown>,
84
96
  ): Error {
85
97
  return Object.assign(new Error(message), fields);
86
98
  }
@@ -246,6 +258,39 @@ export interface GenerateMcpToolsOpts {
246
258
  * path with no extra fetch.
247
259
  */
248
260
  aggregateVisibility?: (note: Note) => boolean;
261
+ /**
262
+ * `AttachmentTicketProvider` seam (vault attachment-tickets design,
263
+ * Wave 1 — D10 "tools omitted when unwired"). When provided,
264
+ * `generateMcpTools` appends `request-attachment-upload` /
265
+ * `request-attachment-download` to the returned tool list. Omitted (a
266
+ * door that hasn't wired its ticket seam yet) → the two tools are
267
+ * ABSENT from the list entirely — not merely erroring on call — so an
268
+ * agent is never shown an affordance the runtime can't back.
269
+ */
270
+ attachmentTickets?: {
271
+ provider: AttachmentTicketProvider;
272
+ /** This vault's own name — stamped onto every minted ticket so the spend route can cheaply reject a cross-vault replay. */
273
+ vaultName: string;
274
+ /**
275
+ * Absolute base URL for this vault's ticket spend path, no trailing
276
+ * slash — e.g. `https://host/vault/<name>`. A minted ticket's URL is
277
+ * `${urlBase}/tickets/<id>`. Resolved by the caller (server layer)
278
+ * from the incoming request's `X-Forwarded-Host`/proto — core stays
279
+ * request-unaware.
280
+ */
281
+ urlBase: string;
282
+ /**
283
+ * OPTIONAL per-note visibility predicate (tag-scope confidentiality —
284
+ * same intent as `expandVisibility`/`ifExistsVisible` above, kept
285
+ * separate because it needs to be awaitable). Both ticket tools run
286
+ * fully async execute() paths (unlike `expandVisibility`/
287
+ * `ifExistsVisible`, which feed core's SYNCHRONOUS wikilink/if_exists
288
+ * code and so need the shared pre-resolved-allowlist-holder
289
+ * machinery), so this can just be awaited inline. Omitted (unscoped /
290
+ * internal callers) → every note/attachment is visible.
291
+ */
292
+ noteVisible?: (note: Note) => boolean | Promise<boolean>;
293
+ };
249
294
  }
250
295
 
251
296
  /**
@@ -312,7 +357,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
312
357
  });
313
358
  };
314
359
 
315
- return [
360
+ const tools: McpToolDef[] = [
316
361
 
317
362
  // =====================================================================
318
363
  // 1. query-notes — the universal read tool
@@ -2779,6 +2824,202 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2779
2824
  },
2780
2825
 
2781
2826
  ];
2827
+
2828
+ // =====================================================================
2829
+ // 12/13. request-attachment-upload / request-attachment-download —
2830
+ // runtime-lane attachment tickets (Wave 1). Present ONLY when the
2831
+ // server layer wires an AttachmentTicketProvider — see
2832
+ // `GenerateMcpToolsOpts.attachmentTickets`'s doc comment (D10, "tools
2833
+ // omitted when unwired"). Bytes never pass through either tool: the
2834
+ // model gets back a URL + a literal curl_example; a runtime with a
2835
+ // shell spends it directly, outside this MCP session entirely.
2836
+ // =====================================================================
2837
+ const ticketSeam = opts?.attachmentTickets;
2838
+ if (ticketSeam) {
2839
+ tools.push(
2840
+ {
2841
+ name: "request-attachment-upload",
2842
+ requiredVerb: "write",
2843
+ description:
2844
+ "Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does. The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
2845
+ inputSchema: {
2846
+ type: "object",
2847
+ properties: {
2848
+ note: { type: "string", description: "Target note ID or path" },
2849
+ filename: { type: "string", description: "Original filename — sanitized for a blocked extension (active-content types: .html/.svg/.xml/.js/.css/…) and used to infer the MIME type when `mime_type` is omitted." },
2850
+ size_bytes: {
2851
+ type: "number",
2852
+ description: `Declared upload size in bytes. Must be > 0 and <= ${MAX_TICKET_UPLOAD_BYTES} (100 MiB — the same ceiling REST's own /storage/upload enforces). The spend endpoint rejects (413) any upload that exceeds this declared size.`,
2853
+ },
2854
+ mime_type: { type: "string", description: "MIME type to store on the attachment row. Inferred from `filename`'s extension when omitted (`application/octet-stream` for an uncurated extension)." },
2855
+ transcribe: { type: "boolean", description: "Opt into transcription for an audio attachment — mirrors the REST `POST /notes/:id/attachments` `transcribe` flag." },
2856
+ },
2857
+ required: ["note", "filename", "size_bytes"],
2858
+ },
2859
+ execute: async (params) => {
2860
+ const noteRef = params.note;
2861
+ if (typeof noteRef !== "string" || noteRef.trim() === "") {
2862
+ throw structuredError("`note` is required", {
2863
+ error_type: "missing_required_field",
2864
+ field: "note",
2865
+ how_to: "pass the target note's id or path",
2866
+ });
2867
+ }
2868
+ const filename = params.filename;
2869
+ if (typeof filename !== "string" || filename.trim() === "") {
2870
+ throw structuredError("`filename` is required", {
2871
+ error_type: "missing_required_field",
2872
+ field: "filename",
2873
+ how_to: "pass the original filename you're about to upload",
2874
+ });
2875
+ }
2876
+ const sizeBytes = params.size_bytes;
2877
+ if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
2878
+ throw structuredError("`size_bytes` must be a positive number", {
2879
+ error_type: "invalid_query",
2880
+ field: "size_bytes",
2881
+ how_to: "pass the exact byte length of the file you're about to upload",
2882
+ });
2883
+ }
2884
+ if (sizeBytes > MAX_TICKET_UPLOAD_BYTES) {
2885
+ throw structuredError(
2886
+ `size_bytes (${sizeBytes}) exceeds the ${MAX_TICKET_UPLOAD_BYTES} byte (100 MiB) upload cap`,
2887
+ {
2888
+ error_type: "file_too_large",
2889
+ field: "size_bytes",
2890
+ limit: MAX_TICKET_UPLOAD_BYTES,
2891
+ got: sizeBytes,
2892
+ how_to: "REST /storage/upload shares this same 100 MiB ceiling — split the file or upload it out-of-band",
2893
+ },
2894
+ );
2895
+ }
2896
+
2897
+ const note = requireNote(db, noteRef);
2898
+ if (ticketSeam.noteVisible && !(await ticketSeam.noteVisible(note))) {
2899
+ // Uniform not_found — a tag-scoped caller learns nothing about
2900
+ // an out-of-scope note's existence (same posture as every other
2901
+ // scope-gated tool in this file).
2902
+ throw structuredError(`Note not found: "${noteRef}"`, { error_type: "not_found", field: "note" });
2903
+ }
2904
+
2905
+ const ext = sanitizeAttachmentExtension(filename);
2906
+ if (BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)) {
2907
+ throw structuredError(`File type ${ext} not allowed (active/executable content)`, {
2908
+ error_type: "blocked_upload_extension",
2909
+ field: "filename",
2910
+ extension: ext,
2911
+ how_to: "rename with a non-executable extension, or store the content as note text instead",
2912
+ });
2913
+ }
2914
+
2915
+ const mimeType =
2916
+ typeof params.mime_type === "string" && params.mime_type.trim() !== ""
2917
+ ? params.mime_type
2918
+ : mimeForAttachmentExtension(ext);
2919
+
2920
+ const now = Date.now();
2921
+ const expiresAt = now + computeTicketTtlMs(sizeBytes);
2922
+ const id = generateTicketId();
2923
+ const ticket: AttachmentTicket = {
2924
+ id,
2925
+ kind: "upload",
2926
+ vaultName: ticketSeam.vaultName,
2927
+ createdAt: now,
2928
+ expiresAt,
2929
+ noteId: note.id,
2930
+ filename,
2931
+ mimeType,
2932
+ sizeBytes,
2933
+ transcribe: params.transcribe === true,
2934
+ };
2935
+ await ticketSeam.provider.put(ticket);
2936
+
2937
+ const url = `${ticketSeam.urlBase}/tickets/${id}`;
2938
+ return {
2939
+ method: "PUT",
2940
+ url,
2941
+ headers: { "content-type": mimeType },
2942
+ expires_at: new Date(expiresAt).toISOString(),
2943
+ max_bytes: sizeBytes,
2944
+ curl_example: `curl -X PUT -H 'content-type: ${mimeType}' --data-binary @${filename} '${url}'`,
2945
+ };
2946
+ },
2947
+ },
2948
+ {
2949
+ name: "request-attachment-download",
2950
+ requiredVerb: "read",
2951
+ description:
2952
+ "Mint a short-lived, single-use download URL for an existing attachment's bytes. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Pass the `attachment_id` from a note's `include_attachments: true` rows (query-notes) or `GET .../attachments`. The ticket's `expires_at` follows the same size-scaled window as upload tickets (10 minutes base, up to 30) and can be spent exactly once.",
2953
+ inputSchema: {
2954
+ type: "object",
2955
+ properties: {
2956
+ attachment_id: { type: "string", description: "The attachment's id (from a note's attachment rows)" },
2957
+ },
2958
+ required: ["attachment_id"],
2959
+ },
2960
+ execute: async (params) => {
2961
+ const attachmentId = params.attachment_id;
2962
+ if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
2963
+ throw structuredError("`attachment_id` is required", {
2964
+ error_type: "missing_required_field",
2965
+ field: "attachment_id",
2966
+ how_to: "pass the attachment id from a note's attachment rows",
2967
+ });
2968
+ }
2969
+
2970
+ const attachment = await store.getAttachment(attachmentId);
2971
+ if (!attachment) {
2972
+ throw structuredError(`Attachment not found: "${attachmentId}"`, {
2973
+ error_type: "not_found",
2974
+ field: "attachment_id",
2975
+ });
2976
+ }
2977
+
2978
+ if (ticketSeam.noteVisible) {
2979
+ const owningNote = await store.getNote(attachment.noteId);
2980
+ // A missing owning note (shouldn't happen — ON DELETE CASCADE —
2981
+ // but never trust it) collapses to the same not_found as an
2982
+ // out-of-scope note: no oracle either way.
2983
+ if (!owningNote || !(await ticketSeam.noteVisible(owningNote))) {
2984
+ throw structuredError(`Attachment not found: "${attachmentId}"`, {
2985
+ error_type: "not_found",
2986
+ field: "attachment_id",
2987
+ });
2988
+ }
2989
+ }
2990
+
2991
+ const metaSize = attachment.metadata?.size;
2992
+ const declaredSize = typeof metaSize === "number" ? metaSize : undefined;
2993
+ const now = Date.now();
2994
+ const expiresAt = now + computeTicketTtlMs(declaredSize);
2995
+ const id = generateTicketId();
2996
+ const ticket: AttachmentTicket = {
2997
+ id,
2998
+ kind: "download",
2999
+ vaultName: ticketSeam.vaultName,
3000
+ createdAt: now,
3001
+ expiresAt,
3002
+ attachmentId: attachment.id,
3003
+ mimeType: attachment.mimeType,
3004
+ sizeBytes: declaredSize,
3005
+ };
3006
+ await ticketSeam.provider.put(ticket);
3007
+
3008
+ const url = `${ticketSeam.urlBase}/tickets/${id}`;
3009
+ return {
3010
+ method: "GET",
3011
+ url,
3012
+ mime_type: attachment.mimeType,
3013
+ ...(declaredSize !== undefined ? { size_bytes: declaredSize } : {}),
3014
+ expires_at: new Date(expiresAt).toISOString(),
3015
+ curl_example: `curl -o downloaded${sanitizeAttachmentExtension(attachment.path) || ""} '${url}'`,
3016
+ };
3017
+ },
3018
+ },
3019
+ );
3020
+ }
3021
+
3022
+ return tools;
2782
3023
  }
2783
3024
 
2784
3025
  // ---------------------------------------------------------------------------
@@ -272,6 +272,42 @@ function sqliteToUserType(t: string): string {
272
272
  return t.toLowerCase();
273
273
  }
274
274
 
275
+ // ---------------------------------------------------------------------------
276
+ // Attachments orientation block (attachment-tickets design, Wave 0+1)
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * The Attachments orientation block, rendered into the connect-time
281
+ * markdown brief (`projectionToMarkdown`) so every agent learns, every
282
+ * session, how to move file bytes into/out of this vault without spending
283
+ * its own context on them (bytes never ride MCP either way).
284
+ *
285
+ * `ticketsEnabled` reflects whether THIS door has wired an
286
+ * `AttachmentTicketProvider` (bun: always, as of this PR; cloud: not yet
287
+ * — its mirror is a separate PR). An unwired door omits BOTH the ticket
288
+ * tools from `tools/list` (see `generateMcpTools`'s `attachmentTickets`
289
+ * opt) AND the ticket-tool sentences here — the brief never dangles a
290
+ * pointer at a tool the agent can't actually call.
291
+ *
292
+ * Kept as a single dense paragraph (not its own multi-line list) to stay
293
+ * inside the connect-time brief's token budget — see
294
+ * `projectionToMarkdown`'s doc comment.
295
+ */
296
+ export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean }): string {
297
+ const sentences: string[] = [
298
+ "Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP).",
299
+ ];
300
+ if (opts.ticketsEnabled) {
301
+ sentences.push(
302
+ "To move bytes, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
303
+ );
304
+ }
305
+ sentences.push(
306
+ "If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer`. Audio attached with `transcribe: true` is transcribed automatically.",
307
+ );
308
+ return sentences.join(" ");
309
+ }
310
+
275
311
  // ---------------------------------------------------------------------------
276
312
  // Markdown rendering — for getServerInstruction
277
313
  // ---------------------------------------------------------------------------
@@ -302,6 +338,13 @@ export function projectionToMarkdown(args: {
302
338
  * known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
303
339
  */
304
340
  coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
341
+ /**
342
+ * Attachments orientation (see `attachmentsInstructionBlock`). Omitted
343
+ * defaults to `{ ticketsEnabled: false }` — safe-by-default so a caller
344
+ * that hasn't wired ticket support yet (or a test fixture) never
345
+ * accidentally advertises tools it can't back.
346
+ */
347
+ attachments?: { ticketsEnabled: boolean };
305
348
  }): string {
306
349
  const { vaultName, description, projection, coordinates } = args;
307
350
  const stats = projection.stats;
@@ -418,6 +461,11 @@ export function projectionToMarkdown(args: {
418
461
  lines.push("");
419
462
  lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
420
463
 
464
+ lines.push("");
465
+ lines.push("## Attachments");
466
+ lines.push("");
467
+ lines.push(attachmentsInstructionBlock(args.attachments ?? { ticketsEnabled: false }));
468
+
421
469
  // Scripting pointer block: the connect-time brief used to dead-end on
422
470
  // querying — an agent had no path to "how do I script/automate against this
423
471
  // vault." Point at the guide rather than inlining it, to keep this brief
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3-rc.6",
3
+ "version": "0.7.3-rc.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",