@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -5
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/core.test.ts +4 -66
- package/core/src/expand.ts +3 -11
- package/core/src/mcp.ts +6 -577
- package/core/src/notes.ts +4 -201
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/types.ts +0 -6
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/auth-hub-jwt.test.ts +1 -118
- package/src/auth.ts +0 -64
- package/src/cli.ts +1 -5
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/oauth-discovery.ts +0 -31
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.test.ts +4 -229
- package/src/routing.ts +23 -152
- package/src/scopes.ts +0 -22
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- package/src/read-attachment.test.ts +0 -436
package/core/src/mcp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { Store, Note, QueryOpts
|
|
2
|
+
import type { Store, Note, QueryOpts } from "./types.js";
|
|
3
3
|
import { transactionAsync } from "./txn.js";
|
|
4
4
|
import * as noteOps from "./notes.js";
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, validatePath } from "./notes.js";
|
|
@@ -43,40 +43,8 @@ import {
|
|
|
43
43
|
parseContentRange,
|
|
44
44
|
applyContentRange,
|
|
45
45
|
contentRangeRequiresContent,
|
|
46
|
-
parseAttachmentContentRange,
|
|
47
|
-
alignByteWindow,
|
|
48
46
|
MIN_CONTENT_LENGTH,
|
|
49
47
|
} from "./content-range.js";
|
|
50
|
-
import {
|
|
51
|
-
BLOCKED_ATTACHMENT_EXTENSIONS,
|
|
52
|
-
ATTACHMENT_MIME_TYPES,
|
|
53
|
-
sanitizeAttachmentExtension,
|
|
54
|
-
mimeForAttachmentExtension,
|
|
55
|
-
} from "./attachment/policy.js";
|
|
56
|
-
import {
|
|
57
|
-
computeTicketTtlMs,
|
|
58
|
-
generateTicketId,
|
|
59
|
-
MAX_TICKET_UPLOAD_BYTES,
|
|
60
|
-
type AttachmentTicket,
|
|
61
|
-
type AttachmentTicketProvider,
|
|
62
|
-
} from "./attachment/tickets.js";
|
|
63
|
-
import {
|
|
64
|
-
MAX_ATTACHMENT_IMAGE_BYTES,
|
|
65
|
-
type AttachmentBytesProvider,
|
|
66
|
-
} from "./attachment/bytes-provider.js";
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* A single MCP tool-result content block. Mirrors the subset of the MCP SDK's
|
|
70
|
-
* `CallToolResult.content` shape this codebase actually emits — a plain text
|
|
71
|
-
* block (the existing, universal shape) or a real image block (`read-attachment`'s
|
|
72
|
-
* image branch, D3). Kept as a local, minimal type rather than importing the
|
|
73
|
-
* SDK's own (broader — audio, resource links, ...) union, since core has no
|
|
74
|
-
* dependency on `@modelcontextprotocol/sdk` today and this is the only shape
|
|
75
|
-
* any tool here produces.
|
|
76
|
-
*/
|
|
77
|
-
export type McpContentBlock =
|
|
78
|
-
| { type: "text"; text: string }
|
|
79
|
-
| { type: "image"; data: string; mimeType: string };
|
|
80
48
|
|
|
81
49
|
export interface McpToolDef {
|
|
82
50
|
name: string;
|
|
@@ -95,17 +63,6 @@ export interface McpToolDef {
|
|
|
95
63
|
* future addition that forgets to stamp this gets the safer treatment.
|
|
96
64
|
*/
|
|
97
65
|
requiredVerb: "read" | "write" | "admin";
|
|
98
|
-
/**
|
|
99
|
-
* OPTIONAL override for how `execute()`'s return value becomes MCP
|
|
100
|
-
* `content` blocks. Every tool WITHOUT this wraps its result as the single
|
|
101
|
-
* `JSON.stringify` text block the HTTP layer has always produced
|
|
102
|
-
* (`src/mcp-http.ts`) — unchanged default behavior. `read-attachment`'s
|
|
103
|
-
* image branch is the only current user: it needs a REAL `{type:"image"}`
|
|
104
|
-
* block alongside the row-JSON text block so the model actually SEES the
|
|
105
|
-
* picture, not just its metadata (D3, attachments-for-agents design "the
|
|
106
|
-
* one wrapper change").
|
|
107
|
-
*/
|
|
108
|
-
resultContent?: (result: unknown) => McpContentBlock[];
|
|
109
66
|
}
|
|
110
67
|
|
|
111
68
|
// ---------------------------------------------------------------------------
|
|
@@ -123,7 +80,7 @@ export interface McpToolDef {
|
|
|
123
80
|
*/
|
|
124
81
|
function structuredError(
|
|
125
82
|
message: string,
|
|
126
|
-
fields: { error_type: string; field?: string; hint?: string }
|
|
83
|
+
fields: { error_type: string; field?: string; hint?: string },
|
|
127
84
|
): Error {
|
|
128
85
|
return Object.assign(new Error(message), fields);
|
|
129
86
|
}
|
|
@@ -197,172 +154,6 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
|
197
154
|
return content;
|
|
198
155
|
}
|
|
199
156
|
|
|
200
|
-
// ---------------------------------------------------------------------------
|
|
201
|
-
// read-attachment helpers (Wave 2 model lane — D2-D5)
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
|
|
204
|
-
/** Non-`text/*` mime types `read-attachment` still treats as text (D2's "text/* + TEXT_MIMES allowlist"). `text/csv` and `text/markdown` already match `text/*` via ATTACHMENT_MIME_TYPES, so they need no entry here. */
|
|
205
|
-
const TEXT_MIME_ALLOWLIST = new Set([
|
|
206
|
-
"application/json",
|
|
207
|
-
"application/ndjson",
|
|
208
|
-
"application/x-ndjson",
|
|
209
|
-
"application/yaml",
|
|
210
|
-
"application/x-yaml",
|
|
211
|
-
]);
|
|
212
|
-
|
|
213
|
-
function baseMime(mimeType: string): string {
|
|
214
|
-
return mimeType.split(";")[0]!.trim().toLowerCase();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function isTextMime(mimeType: string): boolean {
|
|
218
|
-
const base = baseMime(mimeType);
|
|
219
|
-
return base.startsWith("text/") || TEXT_MIME_ALLOWLIST.has(base);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function isImageMime(mimeType: string): boolean {
|
|
223
|
-
return baseMime(mimeType).startsWith("image/");
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function isAudioOrVideoMime(mimeType: string): boolean {
|
|
227
|
-
const base = baseMime(mimeType);
|
|
228
|
-
return base.startsWith("audio/") || base.startsWith("video/");
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Effective mime for `read-attachment` — same discipline the REST byte-serve
|
|
233
|
-
* route uses (`src/routes.ts`'s `GET /storage/<path>`): the stored file's
|
|
234
|
-
* EXTENSION wins over the row's `mime_type` column, since the row's mime is
|
|
235
|
-
* caller-asserted at upload time and never verified against the bytes. Falls
|
|
236
|
-
* back to the row's mime_type, then `application/octet-stream`.
|
|
237
|
-
*/
|
|
238
|
-
function effectiveAttachmentMime(attachment: Attachment): string {
|
|
239
|
-
const ext = sanitizeAttachmentExtension(attachment.path);
|
|
240
|
-
return ATTACHMENT_MIME_TYPES[ext] ?? attachment.mimeType ?? "application/octet-stream";
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Stat the attachment's bytes, or throw the `attachment_binary_missing` refusal (D5's "row outlived bytes" case — e.g. an audio-retention eviction). */
|
|
244
|
-
async function statAttachmentOrMissing(
|
|
245
|
-
attachment: Attachment,
|
|
246
|
-
provider: AttachmentBytesProvider,
|
|
247
|
-
): Promise<{ size: number }> {
|
|
248
|
-
const stat = await provider.stat(attachment);
|
|
249
|
-
if (!stat) {
|
|
250
|
-
throw structuredError(`Attachment binary missing: "${attachment.id}"`, {
|
|
251
|
-
error_type: "attachment_binary_missing",
|
|
252
|
-
how_to:
|
|
253
|
-
"the attachment row exists but its bytes are gone (e.g. an audio-retention eviction after transcription) — this content can't be read",
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
return stat;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Text branch: byte-windowed read using the exact query-notes pagination
|
|
261
|
-
* contract (`content`/`content_offset`/`content_total_length`/
|
|
262
|
-
* `content_next_offset`). Does a BOUNDED positional read — never the whole
|
|
263
|
-
* file — via `alignByteWindow` (see its doc comment for the exact window
|
|
264
|
-
* the provider is asked to fetch).
|
|
265
|
-
*/
|
|
266
|
-
async function readTextAttachment(
|
|
267
|
-
attachment: Attachment,
|
|
268
|
-
mimeType: string,
|
|
269
|
-
params: Record<string, unknown>,
|
|
270
|
-
provider: AttachmentBytesProvider,
|
|
271
|
-
): Promise<Record<string, unknown>> {
|
|
272
|
-
const range = parseAttachmentContentRange(params.content_offset, params.content_length);
|
|
273
|
-
const stat = await statAttachmentOrMissing(attachment, provider);
|
|
274
|
-
const total = stat.size;
|
|
275
|
-
|
|
276
|
-
if (range.offset >= total) {
|
|
277
|
-
return {
|
|
278
|
-
attachment_id: attachment.id,
|
|
279
|
-
mime_type: mimeType,
|
|
280
|
-
content: "",
|
|
281
|
-
content_offset: total,
|
|
282
|
-
content_total_length: total,
|
|
283
|
-
content_next_offset: null,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const rawStart = Math.max(0, range.offset - 3);
|
|
288
|
-
// +1: alignByteWindow's end-boundary check reads the byte AT the window's
|
|
289
|
-
// exclusive end — see its doc comment precondition.
|
|
290
|
-
const rawEnd = Math.min(total, range.offset + range.length + 1);
|
|
291
|
-
const raw = await provider.readRange(attachment, rawStart, rawEnd);
|
|
292
|
-
const fields = alignByteWindow(raw, rawStart, range, total);
|
|
293
|
-
|
|
294
|
-
return {
|
|
295
|
-
attachment_id: attachment.id,
|
|
296
|
-
mime_type: mimeType,
|
|
297
|
-
...fields,
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Image branch: whole-file read, gated by {@link MAX_ATTACHMENT_IMAGE_BYTES}
|
|
303
|
-
* (checked via `stat` BEFORE any bytes are read — an over-cap image never
|
|
304
|
-
* touches `readRange` at all). The base64 payload rides in `_mcpImage`, a
|
|
305
|
-
* field this tool's own `resultContent` consumes to build the real MCP image
|
|
306
|
-
* block and then strips before the text block is serialized (see the tool
|
|
307
|
-
* definition below) — no other caller should read `_mcpImage`.
|
|
308
|
-
*/
|
|
309
|
-
async function readImageAttachment(
|
|
310
|
-
attachment: Attachment,
|
|
311
|
-
mimeType: string,
|
|
312
|
-
provider: AttachmentBytesProvider,
|
|
313
|
-
): Promise<Record<string, unknown>> {
|
|
314
|
-
const stat = await statAttachmentOrMissing(attachment, provider);
|
|
315
|
-
if (stat.size > MAX_ATTACHMENT_IMAGE_BYTES) {
|
|
316
|
-
throw structuredError(
|
|
317
|
-
`Image attachment (${stat.size} bytes) exceeds the ${MAX_ATTACHMENT_IMAGE_BYTES} byte (4 MiB) read cap`,
|
|
318
|
-
{
|
|
319
|
-
error_type: "image_too_large",
|
|
320
|
-
size: stat.size,
|
|
321
|
-
max_bytes: MAX_ATTACHMENT_IMAGE_BYTES,
|
|
322
|
-
how_to: "mint a download ticket with request-attachment-download and process the image locally",
|
|
323
|
-
},
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
const raw = await provider.readRange(attachment, 0, stat.size);
|
|
327
|
-
return {
|
|
328
|
-
attachment_id: attachment.id,
|
|
329
|
-
mime_type: mimeType,
|
|
330
|
-
size_bytes: stat.size,
|
|
331
|
-
_mcpImage: { data: Buffer.from(raw).toString("base64"), mimeType },
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Audio/video branch: never bytes. Returns a transcript pointer built
|
|
337
|
-
* entirely from metadata the transcription pipeline already stamps
|
|
338
|
-
* (`attachment.metadata.transcribe_status`) plus the provider's OPTIONAL
|
|
339
|
-
* sibling-note resolution — no bytes are read or even stat'd.
|
|
340
|
-
*/
|
|
341
|
-
async function readAudioPointer(
|
|
342
|
-
attachment: Attachment,
|
|
343
|
-
provider: AttachmentBytesProvider,
|
|
344
|
-
): Promise<Record<string, unknown>> {
|
|
345
|
-
const meta = attachment.metadata as Record<string, unknown> | undefined;
|
|
346
|
-
const transcribeStatus = typeof meta?.transcribe_status === "string" ? meta.transcribe_status : undefined;
|
|
347
|
-
if (!transcribeStatus) {
|
|
348
|
-
throw structuredError(`Audio/video attachment "${attachment.id}" has no transcript`, {
|
|
349
|
-
error_type: "audio_bytes_not_supported",
|
|
350
|
-
how_to:
|
|
351
|
-
"re-attach with transcribe: true to get a transcript, or mint a download ticket with request-attachment-download to process the bytes locally",
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
const result: Record<string, unknown> = {
|
|
355
|
-
attachment_id: attachment.id,
|
|
356
|
-
transcribe_status: transcribeStatus,
|
|
357
|
-
note_id: attachment.noteId,
|
|
358
|
-
};
|
|
359
|
-
if (provider.resolveTranscriptNote) {
|
|
360
|
-
const transcriptNote = await provider.resolveTranscriptNote(attachment);
|
|
361
|
-
if (transcriptNote) result.transcript_note = transcriptNote;
|
|
362
|
-
}
|
|
363
|
-
return result;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
157
|
// ---------------------------------------------------------------------------
|
|
367
158
|
// Tool generation
|
|
368
159
|
// ---------------------------------------------------------------------------
|
|
@@ -455,58 +246,6 @@ export interface GenerateMcpToolsOpts {
|
|
|
455
246
|
* path with no extra fetch.
|
|
456
247
|
*/
|
|
457
248
|
aggregateVisibility?: (note: Note) => boolean;
|
|
458
|
-
/**
|
|
459
|
-
* `AttachmentTicketProvider` seam (vault attachment-tickets design,
|
|
460
|
-
* Wave 1 — D10 "tools omitted when unwired"). When provided,
|
|
461
|
-
* `generateMcpTools` appends `request-attachment-upload` /
|
|
462
|
-
* `request-attachment-download` to the returned tool list. Omitted (a
|
|
463
|
-
* door that hasn't wired its ticket seam yet) → the two tools are
|
|
464
|
-
* ABSENT from the list entirely — not merely erroring on call — so an
|
|
465
|
-
* agent is never shown an affordance the runtime can't back.
|
|
466
|
-
*/
|
|
467
|
-
attachmentTickets?: {
|
|
468
|
-
provider: AttachmentTicketProvider;
|
|
469
|
-
/** This vault's own name — stamped onto every minted ticket so the spend route can cheaply reject a cross-vault replay. */
|
|
470
|
-
vaultName: string;
|
|
471
|
-
/**
|
|
472
|
-
* Absolute base URL for this vault's ticket spend path, no trailing
|
|
473
|
-
* slash — e.g. `https://host/vault/<name>`. A minted ticket's URL is
|
|
474
|
-
* `${urlBase}/tickets/<id>`. Resolved by the caller (server layer)
|
|
475
|
-
* from the incoming request's `X-Forwarded-Host`/proto — core stays
|
|
476
|
-
* request-unaware.
|
|
477
|
-
*/
|
|
478
|
-
urlBase: string;
|
|
479
|
-
/**
|
|
480
|
-
* OPTIONAL per-note visibility predicate (tag-scope confidentiality —
|
|
481
|
-
* same intent as `expandVisibility`/`ifExistsVisible` above, kept
|
|
482
|
-
* separate because it needs to be awaitable). Both ticket tools run
|
|
483
|
-
* fully async execute() paths (unlike `expandVisibility`/
|
|
484
|
-
* `ifExistsVisible`, which feed core's SYNCHRONOUS wikilink/if_exists
|
|
485
|
-
* code and so need the shared pre-resolved-allowlist-holder
|
|
486
|
-
* machinery), so this can just be awaited inline. Omitted (unscoped /
|
|
487
|
-
* internal callers) → every note/attachment is visible.
|
|
488
|
-
*/
|
|
489
|
-
noteVisible?: (note: Note) => boolean | Promise<boolean>;
|
|
490
|
-
};
|
|
491
|
-
/**
|
|
492
|
-
* `AttachmentBytesProvider` seam (Wave 2 model lane — D10, same "tools
|
|
493
|
-
* omitted when unwired" posture as `attachmentTickets` above). When
|
|
494
|
-
* provided, `generateMcpTools` appends `read-attachment` to the returned
|
|
495
|
-
* tool list. Omitted (a door that hasn't wired byte access yet) →
|
|
496
|
-
* `read-attachment` is ABSENT from the list entirely.
|
|
497
|
-
*/
|
|
498
|
-
attachmentBytes?: {
|
|
499
|
-
provider: AttachmentBytesProvider;
|
|
500
|
-
/**
|
|
501
|
-
* OPTIONAL per-note visibility predicate — identical contract to
|
|
502
|
-
* `attachmentTickets.noteVisible` above (same tag-scope confidentiality
|
|
503
|
-
* intent; kept as a separate field since a caller could in principle
|
|
504
|
-
* wire one seam without the other, though the server layer always wires
|
|
505
|
-
* both from the same underlying check). Omitted → every attachment is
|
|
506
|
-
* visible.
|
|
507
|
-
*/
|
|
508
|
-
noteVisible?: (note: Note) => boolean | Promise<boolean>;
|
|
509
|
-
};
|
|
510
249
|
}
|
|
511
250
|
|
|
512
251
|
/**
|
|
@@ -573,7 +312,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
573
312
|
});
|
|
574
313
|
};
|
|
575
314
|
|
|
576
|
-
|
|
315
|
+
return [
|
|
577
316
|
|
|
578
317
|
// =====================================================================
|
|
579
318
|
// 1. query-notes — the universal read tool
|
|
@@ -771,7 +510,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
771
510
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
772
511
|
expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
|
|
773
512
|
expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
|
|
774
|
-
expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines
|
|
513
|
+
expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines only metadata.summary. Default: 'full'." },
|
|
775
514
|
},
|
|
776
515
|
},
|
|
777
516
|
execute: async (params) => {
|
|
@@ -1880,7 +1619,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1880
1619
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
1881
1620
|
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
|
|
1882
1621
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
1883
|
-
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview
|
|
1622
|
+
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
|
|
1884
1623
|
|
|
1885
1624
|
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
|
|
1886
1625
|
inputSchema: {
|
|
@@ -1954,7 +1693,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1954
1693
|
},
|
|
1955
1694
|
include_content: {
|
|
1956
1695
|
type: "boolean",
|
|
1957
|
-
description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize
|
|
1696
|
+
description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
|
|
1958
1697
|
},
|
|
1959
1698
|
include_links: {
|
|
1960
1699
|
type: "boolean",
|
|
@@ -3040,316 +2779,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
3040
2779
|
},
|
|
3041
2780
|
|
|
3042
2781
|
];
|
|
3043
|
-
|
|
3044
|
-
// =====================================================================
|
|
3045
|
-
// 12/13. request-attachment-upload / request-attachment-download —
|
|
3046
|
-
// runtime-lane attachment tickets (Wave 1). Present ONLY when the
|
|
3047
|
-
// server layer wires an AttachmentTicketProvider — see
|
|
3048
|
-
// `GenerateMcpToolsOpts.attachmentTickets`'s doc comment (D10, "tools
|
|
3049
|
-
// omitted when unwired"). Bytes never pass through either tool: the
|
|
3050
|
-
// model gets back a URL + a literal curl_example; a runtime with a
|
|
3051
|
-
// shell spends it directly, outside this MCP session entirely.
|
|
3052
|
-
// =====================================================================
|
|
3053
|
-
const ticketSeam = opts?.attachmentTickets;
|
|
3054
|
-
if (ticketSeam) {
|
|
3055
|
-
tools.push(
|
|
3056
|
-
{
|
|
3057
|
-
name: "request-attachment-upload",
|
|
3058
|
-
requiredVerb: "write",
|
|
3059
|
-
description:
|
|
3060
|
-
"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.",
|
|
3061
|
-
inputSchema: {
|
|
3062
|
-
type: "object",
|
|
3063
|
-
properties: {
|
|
3064
|
-
note: { type: "string", description: "Target note ID or path" },
|
|
3065
|
-
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." },
|
|
3066
|
-
size_bytes: {
|
|
3067
|
-
type: "number",
|
|
3068
|
-
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.`,
|
|
3069
|
-
},
|
|
3070
|
-
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)." },
|
|
3071
|
-
transcribe: { type: "boolean", description: "Opt into transcription for an audio attachment — mirrors the REST `POST /notes/:id/attachments` `transcribe` flag." },
|
|
3072
|
-
},
|
|
3073
|
-
required: ["note", "filename", "size_bytes"],
|
|
3074
|
-
},
|
|
3075
|
-
execute: async (params) => {
|
|
3076
|
-
const noteRef = params.note;
|
|
3077
|
-
if (typeof noteRef !== "string" || noteRef.trim() === "") {
|
|
3078
|
-
throw structuredError("`note` is required", {
|
|
3079
|
-
error_type: "missing_required_field",
|
|
3080
|
-
field: "note",
|
|
3081
|
-
how_to: "pass the target note's id or path",
|
|
3082
|
-
});
|
|
3083
|
-
}
|
|
3084
|
-
const filename = params.filename;
|
|
3085
|
-
if (typeof filename !== "string" || filename.trim() === "") {
|
|
3086
|
-
throw structuredError("`filename` is required", {
|
|
3087
|
-
error_type: "missing_required_field",
|
|
3088
|
-
field: "filename",
|
|
3089
|
-
how_to: "pass the original filename you're about to upload",
|
|
3090
|
-
});
|
|
3091
|
-
}
|
|
3092
|
-
const sizeBytes = params.size_bytes;
|
|
3093
|
-
if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
|
|
3094
|
-
throw structuredError("`size_bytes` must be a positive number", {
|
|
3095
|
-
error_type: "invalid_query",
|
|
3096
|
-
field: "size_bytes",
|
|
3097
|
-
how_to: "pass the exact byte length of the file you're about to upload",
|
|
3098
|
-
});
|
|
3099
|
-
}
|
|
3100
|
-
if (sizeBytes > MAX_TICKET_UPLOAD_BYTES) {
|
|
3101
|
-
throw structuredError(
|
|
3102
|
-
`size_bytes (${sizeBytes}) exceeds the ${MAX_TICKET_UPLOAD_BYTES} byte (100 MiB) upload cap`,
|
|
3103
|
-
{
|
|
3104
|
-
error_type: "file_too_large",
|
|
3105
|
-
field: "size_bytes",
|
|
3106
|
-
limit: MAX_TICKET_UPLOAD_BYTES,
|
|
3107
|
-
got: sizeBytes,
|
|
3108
|
-
how_to: "REST /storage/upload shares this same 100 MiB ceiling — split the file or upload it out-of-band",
|
|
3109
|
-
},
|
|
3110
|
-
);
|
|
3111
|
-
}
|
|
3112
|
-
|
|
3113
|
-
const note = requireNote(db, noteRef);
|
|
3114
|
-
if (ticketSeam.noteVisible && !(await ticketSeam.noteVisible(note))) {
|
|
3115
|
-
// Uniform not_found — a tag-scoped caller learns nothing about
|
|
3116
|
-
// an out-of-scope note's existence (same posture as every other
|
|
3117
|
-
// scope-gated tool in this file).
|
|
3118
|
-
throw structuredError(`Note not found: "${noteRef}"`, { error_type: "not_found", field: "note" });
|
|
3119
|
-
}
|
|
3120
|
-
|
|
3121
|
-
const ext = sanitizeAttachmentExtension(filename);
|
|
3122
|
-
if (BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)) {
|
|
3123
|
-
throw structuredError(`File type ${ext} not allowed (active/executable content)`, {
|
|
3124
|
-
error_type: "blocked_upload_extension",
|
|
3125
|
-
field: "filename",
|
|
3126
|
-
extension: ext,
|
|
3127
|
-
how_to: "rename with a non-executable extension, or store the content as note text instead",
|
|
3128
|
-
});
|
|
3129
|
-
}
|
|
3130
|
-
|
|
3131
|
-
const mimeType =
|
|
3132
|
-
typeof params.mime_type === "string" && params.mime_type.trim() !== ""
|
|
3133
|
-
? params.mime_type
|
|
3134
|
-
: mimeForAttachmentExtension(ext);
|
|
3135
|
-
|
|
3136
|
-
const now = Date.now();
|
|
3137
|
-
const expiresAt = now + computeTicketTtlMs(sizeBytes);
|
|
3138
|
-
const id = generateTicketId();
|
|
3139
|
-
const ticket: AttachmentTicket = {
|
|
3140
|
-
id,
|
|
3141
|
-
kind: "upload",
|
|
3142
|
-
vaultName: ticketSeam.vaultName,
|
|
3143
|
-
createdAt: now,
|
|
3144
|
-
expiresAt,
|
|
3145
|
-
noteId: note.id,
|
|
3146
|
-
filename,
|
|
3147
|
-
mimeType,
|
|
3148
|
-
sizeBytes,
|
|
3149
|
-
transcribe: params.transcribe === true,
|
|
3150
|
-
};
|
|
3151
|
-
await ticketSeam.provider.put(ticket);
|
|
3152
|
-
|
|
3153
|
-
const url = `${ticketSeam.urlBase}/tickets/${id}`;
|
|
3154
|
-
return {
|
|
3155
|
-
method: "PUT",
|
|
3156
|
-
url,
|
|
3157
|
-
headers: { "content-type": mimeType },
|
|
3158
|
-
expires_at: new Date(expiresAt).toISOString(),
|
|
3159
|
-
max_bytes: sizeBytes,
|
|
3160
|
-
curl_example: `curl -X PUT -H 'content-type: ${mimeType}' --data-binary @${filename} '${url}'`,
|
|
3161
|
-
};
|
|
3162
|
-
},
|
|
3163
|
-
},
|
|
3164
|
-
{
|
|
3165
|
-
name: "request-attachment-download",
|
|
3166
|
-
requiredVerb: "read",
|
|
3167
|
-
description:
|
|
3168
|
-
"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.",
|
|
3169
|
-
inputSchema: {
|
|
3170
|
-
type: "object",
|
|
3171
|
-
properties: {
|
|
3172
|
-
attachment_id: { type: "string", description: "The attachment's id (from a note's attachment rows)" },
|
|
3173
|
-
},
|
|
3174
|
-
required: ["attachment_id"],
|
|
3175
|
-
},
|
|
3176
|
-
execute: async (params) => {
|
|
3177
|
-
const attachmentId = params.attachment_id;
|
|
3178
|
-
if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
|
|
3179
|
-
throw structuredError("`attachment_id` is required", {
|
|
3180
|
-
error_type: "missing_required_field",
|
|
3181
|
-
field: "attachment_id",
|
|
3182
|
-
how_to: "pass the attachment id from a note's attachment rows",
|
|
3183
|
-
});
|
|
3184
|
-
}
|
|
3185
|
-
|
|
3186
|
-
const attachment = await store.getAttachment(attachmentId);
|
|
3187
|
-
if (!attachment) {
|
|
3188
|
-
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
3189
|
-
error_type: "not_found",
|
|
3190
|
-
field: "attachment_id",
|
|
3191
|
-
});
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
if (ticketSeam.noteVisible) {
|
|
3195
|
-
const owningNote = await store.getNote(attachment.noteId);
|
|
3196
|
-
// A missing owning note (shouldn't happen — ON DELETE CASCADE —
|
|
3197
|
-
// but never trust it) collapses to the same not_found as an
|
|
3198
|
-
// out-of-scope note: no oracle either way.
|
|
3199
|
-
if (!owningNote || !(await ticketSeam.noteVisible(owningNote))) {
|
|
3200
|
-
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
3201
|
-
error_type: "not_found",
|
|
3202
|
-
field: "attachment_id",
|
|
3203
|
-
});
|
|
3204
|
-
}
|
|
3205
|
-
}
|
|
3206
|
-
|
|
3207
|
-
const metaSize = attachment.metadata?.size;
|
|
3208
|
-
const declaredSize = typeof metaSize === "number" ? metaSize : undefined;
|
|
3209
|
-
const now = Date.now();
|
|
3210
|
-
const expiresAt = now + computeTicketTtlMs(declaredSize);
|
|
3211
|
-
const id = generateTicketId();
|
|
3212
|
-
const ticket: AttachmentTicket = {
|
|
3213
|
-
id,
|
|
3214
|
-
kind: "download",
|
|
3215
|
-
vaultName: ticketSeam.vaultName,
|
|
3216
|
-
createdAt: now,
|
|
3217
|
-
expiresAt,
|
|
3218
|
-
attachmentId: attachment.id,
|
|
3219
|
-
mimeType: attachment.mimeType,
|
|
3220
|
-
sizeBytes: declaredSize,
|
|
3221
|
-
};
|
|
3222
|
-
await ticketSeam.provider.put(ticket);
|
|
3223
|
-
|
|
3224
|
-
const url = `${ticketSeam.urlBase}/tickets/${id}`;
|
|
3225
|
-
return {
|
|
3226
|
-
method: "GET",
|
|
3227
|
-
url,
|
|
3228
|
-
mime_type: attachment.mimeType,
|
|
3229
|
-
...(declaredSize !== undefined ? { size_bytes: declaredSize } : {}),
|
|
3230
|
-
expires_at: new Date(expiresAt).toISOString(),
|
|
3231
|
-
curl_example: `curl -o downloaded${sanitizeAttachmentExtension(attachment.path) || ""} '${url}'`,
|
|
3232
|
-
};
|
|
3233
|
-
},
|
|
3234
|
-
},
|
|
3235
|
-
);
|
|
3236
|
-
}
|
|
3237
|
-
|
|
3238
|
-
// =====================================================================
|
|
3239
|
-
// 14. read-attachment — model-lane byte reads (Wave 2). Present ONLY
|
|
3240
|
-
// when the server layer wires an AttachmentBytesProvider — see
|
|
3241
|
-
// `GenerateMcpToolsOpts.attachmentBytes`'s doc comment (D10). Dispatches
|
|
3242
|
-
// by mime family: text ranges come back as `content`/`content_offset`/
|
|
3243
|
-
// `content_total_length`/`content_next_offset` (the exact query-notes
|
|
3244
|
-
// pagination contract); images come back as a real MCP image block (see
|
|
3245
|
-
// this tool's `resultContent`); audio/video never send bytes — a
|
|
3246
|
-
// transcript pointer instead; PDF/other binary refuse with a
|
|
3247
|
-
// download-ticket pointer. Unlike the ticket tools, bytes (or a base64
|
|
3248
|
-
// encoding of them) DO pass through this tool — that's the whole point
|
|
3249
|
-
// of the model lane.
|
|
3250
|
-
// =====================================================================
|
|
3251
|
-
const bytesSeam = opts?.attachmentBytes;
|
|
3252
|
-
if (bytesSeam) {
|
|
3253
|
-
tools.push({
|
|
3254
|
-
name: "read-attachment",
|
|
3255
|
-
requiredVerb: "read",
|
|
3256
|
-
description:
|
|
3257
|
-
"Read an attachment's content directly into this conversation (the model lane — bytes DO pass through this tool, unlike request-attachment-upload/download). Behavior depends on mime type: text/* (+ json/ndjson/yaml — csv/markdown are already text/*) returns a byte-windowed `content` slice using the exact query-notes content_offset/content_length/content_next_offset pagination contract (default 65536 bytes / 64 KiB, max 262144 / 256 KiB per call — loop, feeding content_next_offset back as content_offset, for more). image/* returns a real image you can see, capped at 4 MiB raw (over-cap refuses with a pointer to request-attachment-download; content_offset/content_length don't apply to images). audio/video never send bytes — you get back a transcript pointer (transcribe_status, note_id, and a transcript_note when one exists) instead of the audio itself. PDF and other binary formats aren't directly readable here — mint a download ticket with request-attachment-download and process the file with your own runtime.",
|
|
3258
|
-
inputSchema: {
|
|
3259
|
-
type: "object",
|
|
3260
|
-
properties: {
|
|
3261
|
-
attachment_id: {
|
|
3262
|
-
type: "string",
|
|
3263
|
-
description: "The attachment's id (from a note's attachment rows, e.g. include_attachments: true on query-notes)",
|
|
3264
|
-
},
|
|
3265
|
-
content_offset: {
|
|
3266
|
-
type: "number",
|
|
3267
|
-
description: "Text attachments only. Byte offset to start reading from (UTF-8 bytes). Defaults to 0.",
|
|
3268
|
-
},
|
|
3269
|
-
content_length: {
|
|
3270
|
-
type: "number",
|
|
3271
|
-
description: "Text attachments only. Byte budget for this call. Defaults to 65536 (64 KiB); max 262144 (256 KiB).",
|
|
3272
|
-
},
|
|
3273
|
-
},
|
|
3274
|
-
required: ["attachment_id"],
|
|
3275
|
-
},
|
|
3276
|
-
execute: async (params) => {
|
|
3277
|
-
const attachmentId = params.attachment_id;
|
|
3278
|
-
if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
|
|
3279
|
-
throw structuredError("`attachment_id` is required", {
|
|
3280
|
-
error_type: "missing_required_field",
|
|
3281
|
-
field: "attachment_id",
|
|
3282
|
-
how_to: "pass the attachment id from a note's attachment rows",
|
|
3283
|
-
});
|
|
3284
|
-
}
|
|
3285
|
-
|
|
3286
|
-
const attachment = await store.getAttachment(attachmentId);
|
|
3287
|
-
if (!attachment) {
|
|
3288
|
-
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
3289
|
-
error_type: "not_found",
|
|
3290
|
-
field: "attachment_id",
|
|
3291
|
-
});
|
|
3292
|
-
}
|
|
3293
|
-
|
|
3294
|
-
if (bytesSeam.noteVisible) {
|
|
3295
|
-
const owningNote = await store.getNote(attachment.noteId);
|
|
3296
|
-
// Same uniform not_found posture as the ticket tools — a
|
|
3297
|
-
// tag-scoped caller learns nothing about an out-of-scope note's
|
|
3298
|
-
// existence via a differential error.
|
|
3299
|
-
if (!owningNote || !(await bytesSeam.noteVisible(owningNote))) {
|
|
3300
|
-
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
3301
|
-
error_type: "not_found",
|
|
3302
|
-
field: "attachment_id",
|
|
3303
|
-
});
|
|
3304
|
-
}
|
|
3305
|
-
}
|
|
3306
|
-
|
|
3307
|
-
const mimeType = effectiveAttachmentMime(attachment);
|
|
3308
|
-
|
|
3309
|
-
if (isImageMime(mimeType)) {
|
|
3310
|
-
if (params.content_offset !== undefined || params.content_length !== undefined) {
|
|
3311
|
-
throw structuredError("content_offset/content_length don't apply to image attachments", {
|
|
3312
|
-
error_type: "invalid_query",
|
|
3313
|
-
field: "content_offset",
|
|
3314
|
-
hint: "omit content_offset/content_length for an image read — the whole image (up to the 4 MiB cap) comes back in one call",
|
|
3315
|
-
});
|
|
3316
|
-
}
|
|
3317
|
-
return await readImageAttachment(attachment, mimeType, bytesSeam.provider);
|
|
3318
|
-
}
|
|
3319
|
-
if (isTextMime(mimeType)) {
|
|
3320
|
-
return await readTextAttachment(attachment, mimeType, params, bytesSeam.provider);
|
|
3321
|
-
}
|
|
3322
|
-
if (isAudioOrVideoMime(mimeType)) {
|
|
3323
|
-
return await readAudioPointer(attachment, bytesSeam.provider);
|
|
3324
|
-
}
|
|
3325
|
-
|
|
3326
|
-
// Other binary (PDF, zip, docx, ...) — refuse honestly rather than
|
|
3327
|
-
// returning garbage or truncated bytes; extraction is a v2 concern
|
|
3328
|
-
// (D5). Still stats first so a row whose bytes are ALSO gone gets
|
|
3329
|
-
// the more accurate attachment_binary_missing instead.
|
|
3330
|
-
const stat = await statAttachmentOrMissing(attachment, bytesSeam.provider);
|
|
3331
|
-
throw structuredError(`Attachment type "${mimeType}" isn't directly readable by this tool`, {
|
|
3332
|
-
error_type: "unsupported_attachment_type",
|
|
3333
|
-
mime_type: mimeType,
|
|
3334
|
-
size: stat.size,
|
|
3335
|
-
how_to: "mint a download ticket with request-attachment-download and process the file locally",
|
|
3336
|
-
});
|
|
3337
|
-
},
|
|
3338
|
-
resultContent: (result) => {
|
|
3339
|
-
const r = result as ({ _mcpImage?: { data: string; mimeType: string } } & Record<string, unknown>) | null;
|
|
3340
|
-
if (r && r._mcpImage) {
|
|
3341
|
-
const { _mcpImage, ...rest } = r;
|
|
3342
|
-
return [
|
|
3343
|
-
{ type: "text", text: JSON.stringify(rest, null, 2) },
|
|
3344
|
-
{ type: "image", data: _mcpImage.data, mimeType: _mcpImage.mimeType },
|
|
3345
|
-
];
|
|
3346
|
-
}
|
|
3347
|
-
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
3348
|
-
},
|
|
3349
|
-
});
|
|
3350
|
-
}
|
|
3351
|
-
|
|
3352
|
-
return tools;
|
|
3353
2782
|
}
|
|
3354
2783
|
|
|
3355
2784
|
// ---------------------------------------------------------------------------
|