@openparachute/vault 0.7.3-rc.8 → 0.7.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 +5 -3
- package/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range-constants.ts +19 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +105 -8
- package/core/src/core.test.ts +66 -4
- package/core/src/expand.ts +11 -3
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-manifest.test.ts +200 -0
- package/core/src/mcp-manifest.ts +736 -0
- package/core/src/mcp.ts +357 -607
- package/core/src/notes.ts +69 -10
- package/core/src/vault-projection.ts +17 -10
- package/package.json +1 -1
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +126 -1
- package/src/attachment-tickets.ts +77 -1
- package/src/auth-hub-jwt.test.ts +118 -1
- package/src/auth.ts +64 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +17 -0
- package/src/embedding/select.test.ts +58 -30
- package/src/embedding/select.ts +62 -21
- package/src/live-frame-parity.test.ts +21 -0
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/oauth-discovery.ts +31 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/routing.test.ts +229 -4
- package/src/routing.ts +135 -23
- package/src/scopes.ts +22 -0
- package/src/server.ts +17 -8
- package/src/storage.test.ts +200 -1
- package/src/subscriptions.ts +13 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault-embeddings-capability.test.ts +28 -6
- package/src/vault-store-embedding-wiring.test.ts +25 -16
- package/src/vault-store.ts +32 -16
- package/src/vault.test.ts +26 -13
- package/src/ws-server.ts +9 -1
- package/src/ws-subscribe.test.ts +87 -0
- package/src/ws-subscribe.ts +25 -6
package/core/src/mcp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { Store, Note, QueryOpts } from "./types.js";
|
|
2
|
+
import type { Store, Note, QueryOpts, Attachment } 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,10 +43,13 @@ import {
|
|
|
43
43
|
parseContentRange,
|
|
44
44
|
applyContentRange,
|
|
45
45
|
contentRangeRequiresContent,
|
|
46
|
-
|
|
46
|
+
parseAttachmentContentRange,
|
|
47
|
+
alignByteWindow,
|
|
47
48
|
} from "./content-range.js";
|
|
49
|
+
import { MCP_TOOL_MANIFEST } from "./mcp-manifest.js";
|
|
48
50
|
import {
|
|
49
51
|
BLOCKED_ATTACHMENT_EXTENSIONS,
|
|
52
|
+
ATTACHMENT_MIME_TYPES,
|
|
50
53
|
sanitizeAttachmentExtension,
|
|
51
54
|
mimeForAttachmentExtension,
|
|
52
55
|
} from "./attachment/policy.js";
|
|
@@ -57,6 +60,23 @@ import {
|
|
|
57
60
|
type AttachmentTicket,
|
|
58
61
|
type AttachmentTicketProvider,
|
|
59
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 };
|
|
60
80
|
|
|
61
81
|
export interface McpToolDef {
|
|
62
82
|
name: string;
|
|
@@ -75,6 +95,31 @@ export interface McpToolDef {
|
|
|
75
95
|
* future addition that forgets to stamp this gets the safer treatment.
|
|
76
96
|
*/
|
|
77
97
|
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
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The store-bound behavior half of a tool, keyed by tool `name`.
|
|
113
|
+
* `generateMcpTools` builds one of these per tool, then zips it with the
|
|
114
|
+
* matching {@link MCP_TOOL_MANIFEST} entry (which owns name/description/
|
|
115
|
+
* inputSchema/requiredVerb) to produce the final {@link McpToolDef}. Splitting
|
|
116
|
+
* behavior from metadata is what lets the pure-data manifest live in a
|
|
117
|
+
* `bun:sqlite`-free module a front-of-house layer can import.
|
|
118
|
+
*/
|
|
119
|
+
interface McpToolExecutor {
|
|
120
|
+
name: string;
|
|
121
|
+
execute: (params: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
122
|
+
resultContent?: (result: unknown) => McpContentBlock[];
|
|
78
123
|
}
|
|
79
124
|
|
|
80
125
|
// ---------------------------------------------------------------------------
|
|
@@ -166,6 +211,172 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
|
166
211
|
return content;
|
|
167
212
|
}
|
|
168
213
|
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// read-attachment helpers (Wave 2 model lane — D2-D5)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/** 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. */
|
|
219
|
+
const TEXT_MIME_ALLOWLIST = new Set([
|
|
220
|
+
"application/json",
|
|
221
|
+
"application/ndjson",
|
|
222
|
+
"application/x-ndjson",
|
|
223
|
+
"application/yaml",
|
|
224
|
+
"application/x-yaml",
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
function baseMime(mimeType: string): string {
|
|
228
|
+
return mimeType.split(";")[0]!.trim().toLowerCase();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isTextMime(mimeType: string): boolean {
|
|
232
|
+
const base = baseMime(mimeType);
|
|
233
|
+
return base.startsWith("text/") || TEXT_MIME_ALLOWLIST.has(base);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isImageMime(mimeType: string): boolean {
|
|
237
|
+
return baseMime(mimeType).startsWith("image/");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isAudioOrVideoMime(mimeType: string): boolean {
|
|
241
|
+
const base = baseMime(mimeType);
|
|
242
|
+
return base.startsWith("audio/") || base.startsWith("video/");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Effective mime for `read-attachment` — same discipline the REST byte-serve
|
|
247
|
+
* route uses (`src/routes.ts`'s `GET /storage/<path>`): the stored file's
|
|
248
|
+
* EXTENSION wins over the row's `mime_type` column, since the row's mime is
|
|
249
|
+
* caller-asserted at upload time and never verified against the bytes. Falls
|
|
250
|
+
* back to the row's mime_type, then `application/octet-stream`.
|
|
251
|
+
*/
|
|
252
|
+
function effectiveAttachmentMime(attachment: Attachment): string {
|
|
253
|
+
const ext = sanitizeAttachmentExtension(attachment.path);
|
|
254
|
+
return ATTACHMENT_MIME_TYPES[ext] ?? attachment.mimeType ?? "application/octet-stream";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Stat the attachment's bytes, or throw the `attachment_binary_missing` refusal (D5's "row outlived bytes" case — e.g. an audio-retention eviction). */
|
|
258
|
+
async function statAttachmentOrMissing(
|
|
259
|
+
attachment: Attachment,
|
|
260
|
+
provider: AttachmentBytesProvider,
|
|
261
|
+
): Promise<{ size: number }> {
|
|
262
|
+
const stat = await provider.stat(attachment);
|
|
263
|
+
if (!stat) {
|
|
264
|
+
throw structuredError(`Attachment binary missing: "${attachment.id}"`, {
|
|
265
|
+
error_type: "attachment_binary_missing",
|
|
266
|
+
how_to:
|
|
267
|
+
"the attachment row exists but its bytes are gone (e.g. an audio-retention eviction after transcription) — this content can't be read",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return stat;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Text branch: byte-windowed read using the exact query-notes pagination
|
|
275
|
+
* contract (`content`/`content_offset`/`content_total_length`/
|
|
276
|
+
* `content_next_offset`). Does a BOUNDED positional read — never the whole
|
|
277
|
+
* file — via `alignByteWindow` (see its doc comment for the exact window
|
|
278
|
+
* the provider is asked to fetch).
|
|
279
|
+
*/
|
|
280
|
+
async function readTextAttachment(
|
|
281
|
+
attachment: Attachment,
|
|
282
|
+
mimeType: string,
|
|
283
|
+
params: Record<string, unknown>,
|
|
284
|
+
provider: AttachmentBytesProvider,
|
|
285
|
+
): Promise<Record<string, unknown>> {
|
|
286
|
+
const range = parseAttachmentContentRange(params.content_offset, params.content_length);
|
|
287
|
+
const stat = await statAttachmentOrMissing(attachment, provider);
|
|
288
|
+
const total = stat.size;
|
|
289
|
+
|
|
290
|
+
if (range.offset >= total) {
|
|
291
|
+
return {
|
|
292
|
+
attachment_id: attachment.id,
|
|
293
|
+
mime_type: mimeType,
|
|
294
|
+
content: "",
|
|
295
|
+
content_offset: total,
|
|
296
|
+
content_total_length: total,
|
|
297
|
+
content_next_offset: null,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const rawStart = Math.max(0, range.offset - 3);
|
|
302
|
+
// +1: alignByteWindow's end-boundary check reads the byte AT the window's
|
|
303
|
+
// exclusive end — see its doc comment precondition.
|
|
304
|
+
const rawEnd = Math.min(total, range.offset + range.length + 1);
|
|
305
|
+
const raw = await provider.readRange(attachment, rawStart, rawEnd);
|
|
306
|
+
const fields = alignByteWindow(raw, rawStart, range, total);
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
attachment_id: attachment.id,
|
|
310
|
+
mime_type: mimeType,
|
|
311
|
+
...fields,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Image branch: whole-file read, gated by {@link MAX_ATTACHMENT_IMAGE_BYTES}
|
|
317
|
+
* (checked via `stat` BEFORE any bytes are read — an over-cap image never
|
|
318
|
+
* touches `readRange` at all). The base64 payload rides in `_mcpImage`, a
|
|
319
|
+
* field this tool's own `resultContent` consumes to build the real MCP image
|
|
320
|
+
* block and then strips before the text block is serialized (see the tool
|
|
321
|
+
* definition below) — no other caller should read `_mcpImage`.
|
|
322
|
+
*/
|
|
323
|
+
async function readImageAttachment(
|
|
324
|
+
attachment: Attachment,
|
|
325
|
+
mimeType: string,
|
|
326
|
+
provider: AttachmentBytesProvider,
|
|
327
|
+
): Promise<Record<string, unknown>> {
|
|
328
|
+
const stat = await statAttachmentOrMissing(attachment, provider);
|
|
329
|
+
if (stat.size > MAX_ATTACHMENT_IMAGE_BYTES) {
|
|
330
|
+
throw structuredError(
|
|
331
|
+
`Image attachment (${stat.size} bytes) exceeds the ${MAX_ATTACHMENT_IMAGE_BYTES} byte (4 MiB) read cap`,
|
|
332
|
+
{
|
|
333
|
+
error_type: "image_too_large",
|
|
334
|
+
size: stat.size,
|
|
335
|
+
max_bytes: MAX_ATTACHMENT_IMAGE_BYTES,
|
|
336
|
+
how_to: "mint a download ticket with request-attachment-download and process the image locally",
|
|
337
|
+
},
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const raw = await provider.readRange(attachment, 0, stat.size);
|
|
341
|
+
return {
|
|
342
|
+
attachment_id: attachment.id,
|
|
343
|
+
mime_type: mimeType,
|
|
344
|
+
size_bytes: stat.size,
|
|
345
|
+
_mcpImage: { data: Buffer.from(raw).toString("base64"), mimeType },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Audio/video branch: never bytes. Returns a transcript pointer built
|
|
351
|
+
* entirely from metadata the transcription pipeline already stamps
|
|
352
|
+
* (`attachment.metadata.transcribe_status`) plus the provider's OPTIONAL
|
|
353
|
+
* sibling-note resolution — no bytes are read or even stat'd.
|
|
354
|
+
*/
|
|
355
|
+
async function readAudioPointer(
|
|
356
|
+
attachment: Attachment,
|
|
357
|
+
provider: AttachmentBytesProvider,
|
|
358
|
+
): Promise<Record<string, unknown>> {
|
|
359
|
+
const meta = attachment.metadata as Record<string, unknown> | undefined;
|
|
360
|
+
const transcribeStatus = typeof meta?.transcribe_status === "string" ? meta.transcribe_status : undefined;
|
|
361
|
+
if (!transcribeStatus) {
|
|
362
|
+
throw structuredError(`Audio/video attachment "${attachment.id}" has no transcript`, {
|
|
363
|
+
error_type: "audio_bytes_not_supported",
|
|
364
|
+
how_to:
|
|
365
|
+
"re-attach with transcribe: true to get a transcript, or mint a download ticket with request-attachment-download to process the bytes locally",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const result: Record<string, unknown> = {
|
|
369
|
+
attachment_id: attachment.id,
|
|
370
|
+
transcribe_status: transcribeStatus,
|
|
371
|
+
note_id: attachment.noteId,
|
|
372
|
+
};
|
|
373
|
+
if (provider.resolveTranscriptNote) {
|
|
374
|
+
const transcriptNote = await provider.resolveTranscriptNote(attachment);
|
|
375
|
+
if (transcriptNote) result.transcript_note = transcriptNote;
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
|
|
169
380
|
// ---------------------------------------------------------------------------
|
|
170
381
|
// Tool generation
|
|
171
382
|
// ---------------------------------------------------------------------------
|
|
@@ -291,6 +502,25 @@ export interface GenerateMcpToolsOpts {
|
|
|
291
502
|
*/
|
|
292
503
|
noteVisible?: (note: Note) => boolean | Promise<boolean>;
|
|
293
504
|
};
|
|
505
|
+
/**
|
|
506
|
+
* `AttachmentBytesProvider` seam (Wave 2 model lane — D10, same "tools
|
|
507
|
+
* omitted when unwired" posture as `attachmentTickets` above). When
|
|
508
|
+
* provided, `generateMcpTools` appends `read-attachment` to the returned
|
|
509
|
+
* tool list. Omitted (a door that hasn't wired byte access yet) →
|
|
510
|
+
* `read-attachment` is ABSENT from the list entirely.
|
|
511
|
+
*/
|
|
512
|
+
attachmentBytes?: {
|
|
513
|
+
provider: AttachmentBytesProvider;
|
|
514
|
+
/**
|
|
515
|
+
* OPTIONAL per-note visibility predicate — identical contract to
|
|
516
|
+
* `attachmentTickets.noteVisible` above (same tag-scope confidentiality
|
|
517
|
+
* intent; kept as a separate field since a caller could in principle
|
|
518
|
+
* wire one seam without the other, though the server layer always wires
|
|
519
|
+
* both from the same underlying check). Omitted → every attachment is
|
|
520
|
+
* visible.
|
|
521
|
+
*/
|
|
522
|
+
noteVisible?: (note: Note) => boolean | Promise<boolean>;
|
|
523
|
+
};
|
|
294
524
|
}
|
|
295
525
|
|
|
296
526
|
/**
|
|
@@ -357,207 +587,17 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
357
587
|
});
|
|
358
588
|
};
|
|
359
589
|
|
|
360
|
-
|
|
590
|
+
// Store-bound behavior, keyed by tool name. Metadata (name/description/
|
|
591
|
+
// inputSchema/requiredVerb + inclusion condition) lives in MCP_TOOL_MANIFEST;
|
|
592
|
+
// the return step below zips the two together. Order here is irrelevant —
|
|
593
|
+
// the emitted tool order comes from the manifest.
|
|
594
|
+
const executorDefs: McpToolExecutor[] = [
|
|
361
595
|
|
|
362
596
|
// =====================================================================
|
|
363
597
|
// 1. query-notes — the universal read tool
|
|
364
598
|
// =====================================================================
|
|
365
599
|
{
|
|
366
600
|
name: "query-notes",
|
|
367
|
-
requiredVerb: "read",
|
|
368
|
-
description: `Query notes. Returns notes matching the given filters.
|
|
369
|
-
|
|
370
|
-
- **Single note**: pass \`id\` (accepts note ID, path, e.g., "Projects/README", or — as a last-resort fallback when id/path both miss cleanly and exactly one note matches — its H1 title, e.g. "Weekly Review")
|
|
371
|
-
- **Filter**: pass \`tag\`, \`path\`, \`path_prefix\`, \`search\`, \`metadata\`, date range
|
|
372
|
-
- **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
|
|
373
|
-
- **No filters**: returns all notes (paginated)
|
|
374
|
-
|
|
375
|
-
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
|
|
376
|
-
|
|
377
|
-
Each result carries \`validation_status\` when any tag it carries declares \`fields\` (vault#555) — same advisory-warnings shape create-note/update-note attach, now also on reads (an out-of-enum value on a non-strict field is stored and findable, but still surfaces its \`enum_mismatch\` warning here, not just on the write that introduced it). Absent entirely when no tag on the note declares a schema.
|
|
378
|
-
|
|
379
|
-
Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
|
|
380
|
-
|
|
381
|
-
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
|
|
382
|
-
|
|
383
|
-
Broken links (vault#555): a \`[[wikilink]]\` or structured \`links\` target that never resolved to a note used to be invisible — silently dropped from the response with no signal it existed. Pass \`has_broken_links: true\`/\`false\` to filter notes by whether they have any dangling outbound link, and/or \`include_broken_links: true\` to attach each note's pending targets as \`broken_links: [{target, relationship}]\` (empty array when none). Both read the vault's pending-resolution table — the same source \`create-note\`/\`update-note\`'s \`unresolved_link\` warning draws from; a target created later (this session or any future one) backfills the edge automatically and the note drops out of \`has_broken_links: true\`.
|
|
384
|
-
|
|
385
|
-
Response shape (vault#550 — three variants, pick by what you passed):
|
|
386
|
-
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
387
|
-
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
388
|
-
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
|
|
389
|
-
- \`aggregate\` mode: \`[{group, value}]\` — a rollup row per group, NOT notes. See \`aggregate\` below.
|
|
390
|
-
|
|
391
|
-
\`aggregate\` (group_by + count/sum): pass \`aggregate: {group_by, op, field?}\` to get counts/sums instead of note rows — e.g. "how many notes per status" (\`{group_by: "status", op: "count"}\`) or "total amount per category" (\`{group_by: "category", op: "sum", field: "amount"}\`). Every other filter (\`tag\`, \`metadata\`, date range, ...) narrows the input set FIRST, exactly like a normal query. \`group_by\` is either \"tag\" (group by tag membership) or an indexed metadata field; \`op: "sum"\` additionally requires \`field\` to be an indexed NUMERIC field. Mutually exclusive with \`search\`/\`near\`/\`cursor\`.
|
|
392
|
-
|
|
393
|
-
\`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.
|
|
394
|
-
|
|
395
|
-
\`search\` indexes BOTH a note's title (\`path\`) and its \`content\` (vault#551 WS2C, schema v25) — a title match is weighted far above a passing body mention, so a dedicated note on a topic outranks another note that merely references it. Every result carries a \`score\` field (higher = more relevant; only meaningful as a RELATIVE comparison within one result set). Word matching also stems regular English affixes ("firefighter" matches "firefighters", "microbe" matches "microbes") — irregular plurals with a consonant change ("wolf"/"wolves") aren't covered by stemming. A search that returns ZERO results may carry a \`search_did_you_mean\` warning suggesting the closest indexed term when one looks like a likely typo (only unscoped sessions — tag-scoped tokens never see it, since the suggestion is computed vault-wide).`,
|
|
396
|
-
inputSchema: {
|
|
397
|
-
type: "object",
|
|
398
|
-
properties: {
|
|
399
|
-
id: { type: "string", description: "Get one note by ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
400
|
-
tag: {
|
|
401
|
-
oneOf: [
|
|
402
|
-
{ type: "string" },
|
|
403
|
-
{ type: "array", items: { type: "string" } },
|
|
404
|
-
],
|
|
405
|
-
description: "Filter by tag(s)",
|
|
406
|
-
},
|
|
407
|
-
tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
|
|
408
|
-
expand: {
|
|
409
|
-
type: "string",
|
|
410
|
-
enum: ["subtypes", "namespace", "both", "exact"],
|
|
411
|
-
description: "How each `tag` expands. 'subtypes' (DEFAULT): the tag plus its declared parent_names descendants — the semantic is-a axis (e.g. tag:entity also matches person/work). 'namespace': the tag plus everything filed under it by NAME (tag:entity also matches entity/archived) — the lexical filing axis. 'both': union of the two. 'exact': only the literal tag, no expansion. Omit for 'subtypes' (current behavior).",
|
|
412
|
-
},
|
|
413
|
-
exclude_tags: {
|
|
414
|
-
oneOf: [
|
|
415
|
-
{ type: "string" },
|
|
416
|
-
{ type: "array", items: { type: "string" } },
|
|
417
|
-
],
|
|
418
|
-
description: "Exclude notes with these tag(s). Accepts a single tag or an array. Aliases `excludeTags` and `exclude_tag` are also accepted. If multiple alias forms are provided, `exclude_tags` takes precedence (then `excludeTags`, then `exclude_tag`).",
|
|
419
|
-
},
|
|
420
|
-
// The runtime alias-fallback chain accepts these too. Declared
|
|
421
|
-
// here so schema-introspecting clients (Claude, MCP clients
|
|
422
|
-
// that surface tool schemas) see them as valid inputs rather
|
|
423
|
-
// than thinking the canonical is the only option.
|
|
424
|
-
excludeTags: {
|
|
425
|
-
oneOf: [
|
|
426
|
-
{ type: "string" },
|
|
427
|
-
{ type: "array", items: { type: "string" } },
|
|
428
|
-
],
|
|
429
|
-
description: "Alias for `exclude_tags` (camelCase). Same shape and semantics — pick whichever is more natural for your client.",
|
|
430
|
-
},
|
|
431
|
-
exclude_tag: {
|
|
432
|
-
oneOf: [
|
|
433
|
-
{ type: "string" },
|
|
434
|
-
{ type: "array", items: { type: "string" } },
|
|
435
|
-
],
|
|
436
|
-
description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
|
|
437
|
-
},
|
|
438
|
-
has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
|
|
439
|
-
has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
|
|
440
|
-
has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op)." },
|
|
441
|
-
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
442
|
-
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
443
|
-
extension: {
|
|
444
|
-
oneOf: [
|
|
445
|
-
{ type: "string" },
|
|
446
|
-
{ type: "array", items: { type: "string" } },
|
|
447
|
-
],
|
|
448
|
-
description: "Filter by file extension (vault#328). Pass a single extension (e.g. \"csv\") or an array (e.g. [\"csv\", \"yaml\", \"json\"]). Notes default to \"md\"; case-insensitive match.",
|
|
449
|
-
},
|
|
450
|
-
search: {
|
|
451
|
-
type: "string",
|
|
452
|
-
description:
|
|
453
|
-
'Full-text search query, matched against BOTH a note\'s title (path) and its content — a title match ranks far above a passing body mention. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking. Matching stems regular affixes ("firefighter"/"firefighters") but not irregular plurals ("wolf"/"wolves"). Results carry a `score` field (higher = more relevant, relative within this result set only). A zero-result search may carry a `search_did_you_mean` warning (unscoped sessions only).',
|
|
454
|
-
},
|
|
455
|
-
search_mode: {
|
|
456
|
-
type: "string",
|
|
457
|
-
enum: [...SEARCH_MODES],
|
|
458
|
-
description:
|
|
459
|
-
'How `search` text is turned into an FTS5 query (vault#551). "literal" (DEFAULT): escape + phrase-quote the text so punctuation is literal content, not FTS5 syntax — the fix for `search: "didn\'t"` / "eleven-day" / "18.6" silently returning `[]`. "advanced": pass the text through to FTS5 raw, for callers who want boolean (AND/OR/NOT), manual phrase quoting, or prefix (`*`) syntax — a malformed advanced query throws a structured error (`error_type: "invalid_search_syntax"`) instead of silently returning `[]`. Has no effect without `search` (an `ignored_param` warning fires if you pass it without `search`). Omit for the default ("literal").',
|
|
460
|
-
},
|
|
461
|
-
near_text: {
|
|
462
|
-
type: "string",
|
|
463
|
-
description:
|
|
464
|
-
'EXPERIMENTAL (semantic search MVP — may change or be removed while quality is validated). Free text to rank notes by MEANING rather than keyword — "that idea about music remixes as community building" finds the note even if it never uses those exact words. Requires `semantic: true`; mutually exclusive with `search`/`aggregate`/`cursor`. Composes with every other filter (`tag`, `metadata`, date range, ...) exactly like `search` does — those narrow the candidate set FIRST, then ranking runs over just that set. Long notes are chunked internally and ranked by their BEST-matching section, so a match buried in one part of a long note still surfaces the whole note. Results carry a `score` field — cosine similarity in `[-1, 1]` (typically 0.2–0.9), NOT the same scale as `search`\'s bm25 `score`; only meaningful as a relative ranking within one result set.',
|
|
465
|
-
},
|
|
466
|
-
semantic: {
|
|
467
|
-
type: "boolean",
|
|
468
|
-
description:
|
|
469
|
-
'EXPERIMENTAL. Opt into vector ranking via `near_text` (required when true). No embedding provider configured, or the vault hasn\'t finished indexing, is reported HONESTLY — never a silent fallback to keyword search: a provider-less vault throws a structured `semantic_unavailable` error; a mid-backfill vault returns real (possibly partial) results plus an `embeddings_pending` warning naming how many candidate notes aren\'t embedded yet.',
|
|
470
|
-
},
|
|
471
|
-
metadata: {
|
|
472
|
-
type: "object",
|
|
473
|
-
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
474
|
-
},
|
|
475
|
-
created_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose FIRST write was attributed to this principal (a JWT subject, or an operator/token label). Exact match; indexed. Legacy/unattributed notes (NULL) never match." },
|
|
476
|
-
last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
|
|
477
|
-
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
|
|
478
|
-
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
|
|
479
|
-
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
|
|
480
|
-
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
481
|
-
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
482
|
-
date_filter: {
|
|
483
|
-
type: "object",
|
|
484
|
-
properties: {
|
|
485
|
-
field: { type: "string", description: "Field to filter on. Defaults to `created_at` (vault ingestion time). `updated_at` is also recognized as a real column — use it for incremental rebuilds (\"what changed since X\"). Any other field must be declared `indexed: true` in a tag schema — same contract as metadata operator queries and `order_by`." },
|
|
486
|
-
from: { type: "string", description: "Inclusive lower bound (ISO date)." },
|
|
487
|
-
to: { type: "string", description: "Exclusive upper bound (ISO date)." },
|
|
488
|
-
},
|
|
489
|
-
description: "Generalized date-range filter. Use this when the date that matters is the *content* date (e.g. an email's received date, a meeting's scheduled date) rather than the vault ingestion time, or when paging by `updated_at` for incremental rebuilds. Mutually exclusive with the top-level `date_from` / `date_to` shorthand.",
|
|
490
|
-
},
|
|
491
|
-
aggregate: {
|
|
492
|
-
type: "object",
|
|
493
|
-
properties: {
|
|
494
|
-
group_by: { type: "string", description: "What to group by: an indexed metadata field name (declared `indexed: true` in a tag schema — same FIELD_NOT_INDEXED contract as `metadata` operator queries / `order_by`), or the special value \"tag\" to group by tag membership. Under \"tag\", a note carrying N of the tags present in the filtered result set contributes to N separate groups (a membership rollup, not a partition)." },
|
|
495
|
-
op: { type: "string", enum: ["count", "sum"], description: "\"count\": number of matching notes per group. \"sum\": sum of `field` per group." },
|
|
496
|
-
field: { type: "string", description: "Required when `op` is \"sum\"; ignored for \"count\". Must be an indexed metadata field with a numeric storage type (declared `type: \"integer\"` or `type: \"boolean\"` — the only indexable numeric shapes; a bare `type: \"number\"` field is never indexed and a TEXT-backed field can't be summed)." },
|
|
497
|
-
},
|
|
498
|
-
required: ["group_by", "op"],
|
|
499
|
-
description: "Aggregation / rollup mode. Every OTHER filter above (tag, metadata, date range, write-attribution, ...) is applied FIRST, exactly as a normal query would; the matching notes are then grouped and the response becomes `[{group, value}]` instead of note rows — one row per group, `value` is the count/sum. A note whose group_by value is absent collects into one `{group: null, value: ...}` row rather than being dropped. Mutually exclusive with `search`, `near`, and `cursor` (a rollup has no pagination/ranking/graph-neighborhood shape). Tag-scoped sessions see the SAME visibility enforcement as every other read — the rollup is computed only over notes the token can see.",
|
|
500
|
-
},
|
|
501
|
-
near: {
|
|
502
|
-
type: "object",
|
|
503
|
-
properties: {
|
|
504
|
-
note_id: { type: "string", description: "Anchor note ID, path, or (fallback) H1 title" },
|
|
505
|
-
depth: { type: "number", description: "Max hops from anchor (default 2, max 5)" },
|
|
506
|
-
relationship: { type: "string", description: "Only follow links with this relationship" },
|
|
507
|
-
},
|
|
508
|
-
required: ["note_id"],
|
|
509
|
-
description: "Scope results to notes within N hops of an anchor note",
|
|
510
|
-
},
|
|
511
|
-
sort: {
|
|
512
|
-
type: "string",
|
|
513
|
-
enum: ["asc", "desc"],
|
|
514
|
-
description:
|
|
515
|
-
'Sort by created_at. Under a structured query this is the only ordering (default "asc"). Under `search` (vault#551): omit for FTS5 relevance ranking (default, unchanged) — pass "asc"/"desc" to EXPLICITLY switch to created_at ordering instead of relevance.',
|
|
516
|
-
},
|
|
517
|
-
limit: { type: "number", description: "Max results (default 50)" },
|
|
518
|
-
offset: { type: "number", description: "Pagination offset (default 0)" },
|
|
519
|
-
cursor: {
|
|
520
|
-
type: "string",
|
|
521
|
-
description:
|
|
522
|
-
"Opaque cursor for 'since last checked' agent loops (vault#313). Bootstrap flow (vault#550): FIRST call passes `cursor: \"\"` (empty string) — this opts into cursor mode with no watermark yet and the response comes back as `{notes, next_cursor}`. Persist `next_cursor` and pass it back verbatim as `cursor` on every SUBSEQUENT call to receive only notes created or updated since the prior page. Omitting `cursor` entirely (not passing the key at all) is a DIFFERENT thing — a plain one-shot list with no cursor envelope and no way to resume; use that when you don't want pagination at all. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error, and a malformed/expired cursor returns `cursor_invalid` naming the bootstrap flow again. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`.",
|
|
523
|
-
},
|
|
524
|
-
include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
|
|
525
|
-
content_offset: {
|
|
526
|
-
type: "number",
|
|
527
|
-
description:
|
|
528
|
-
"Byte offset (UTF-8) into note content to start reading from (default 0). For reading a note too large for one response: pass the previous response's `content_next_offset` here to continue. An offset landing mid-codepoint is aligned DOWN to the codepoint's leading byte (chained `content_next_offset` values are always aligned); the effective start is echoed back as `content_offset` on the response. Requires content in the response — errors when combined with include_content=false (or a list query without include_content=true).",
|
|
529
|
-
},
|
|
530
|
-
content_length: {
|
|
531
|
-
type: "number",
|
|
532
|
-
description:
|
|
533
|
-
`Maximum bytes (UTF-8) of note content to return (minimum ${MIN_CONTENT_LENGTH}). When this or content_offset is set, the returned \`content\` is the byte slice and the response gains \`content_offset\` (effective start), \`content_total_length\` (full content size in bytes), and \`content_next_offset\` (pass back as content_offset to continue; null when the slice reaches the end). Slices end on a UTF-8 codepoint boundary, so a slice may be up to 3 bytes under the budget — never over. Concatenating the slices from offset 0 through content_next_offset=null reconstructs the content byte-for-byte. On list queries the same window applies to each note's content independently. When expand_links=true the range applies to the returned (expanded) content.`,
|
|
534
|
-
},
|
|
535
|
-
include_metadata: {
|
|
536
|
-
oneOf: [
|
|
537
|
-
{ type: "boolean" },
|
|
538
|
-
{ type: "array", items: { type: "string" } },
|
|
539
|
-
],
|
|
540
|
-
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
541
|
-
},
|
|
542
|
-
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
543
|
-
include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`." },
|
|
544
|
-
include_link_count: {
|
|
545
|
-
type: "boolean",
|
|
546
|
-
description:
|
|
547
|
-
"Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, `linkCount` is the raw degree and MAY include edges to notes the token can't see — only the number leaks, not the neighbor.",
|
|
548
|
-
},
|
|
549
|
-
link_count_direction: {
|
|
550
|
-
type: "string",
|
|
551
|
-
enum: ["both", "outbound", "inbound"],
|
|
552
|
-
description:
|
|
553
|
-
"Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses the both-directions degree.",
|
|
554
|
-
},
|
|
555
|
-
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
556
|
-
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." },
|
|
557
|
-
expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
|
|
558
|
-
expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines only metadata.summary. Default: 'full'." },
|
|
559
|
-
},
|
|
560
|
-
},
|
|
561
601
|
execute: async (params) => {
|
|
562
602
|
// --- Link expansion config (shared across single + list paths) ---
|
|
563
603
|
const expandLinks = params.expand_links === true;
|
|
@@ -1200,69 +1240,6 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
1200
1240
|
// =====================================================================
|
|
1201
1241
|
{
|
|
1202
1242
|
name: "create-note",
|
|
1203
|
-
requiredVerb: "write",
|
|
1204
|
-
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.
|
|
1205
|
-
|
|
1206
|
-
**Path-conflict handling** — \`if_exists: "error"|"ignore"|"update"|"replace"\` (vault#555, default \`"error"\`): what to do when the note's \`path\` already names an existing note.
|
|
1207
|
-
- \`"error"\` (DEFAULT — unchanged behavior): the write is rejected with a \`path_conflict\` error (409); nothing is mutated.
|
|
1208
|
-
- \`"ignore"\`: return the existing note UNCHANGED — no error and no mutation of any kind (content/metadata/tags/links untouched; no schema-default backfill runs either). Response carries \`existed: true\`. The idempotent-retry primitive: a crash-replay or the losing side of a create-race gets back the same note a first-time caller would have created, safely, any number of times.
|
|
1209
|
-
- \`"update"\`: merge this payload into the existing note — \`content\` (if provided) fully replaces the existing content, exactly like \`update-note\`'s \`content\` field (omit to leave it untouched); \`metadata\` (if provided) is RFC-7386 merged — existing keys preserved, incoming keys overwrite, an incoming \`null\` value deletes a key — same semantics as \`update-note\`; \`tags\`/\`links\` (if provided) are ADDED to the existing set (union — nothing already there is removed). Response carries \`existed: true\`.
|
|
1210
|
-
- \`"replace"\`: overwrite \`content\` and \`metadata\` WHOLESALE — \`content\` becomes exactly the incoming value (or \`""\` if omitted) and \`metadata\` becomes exactly the incoming object (or \`{}\` if omitted), NOT merged, so a prior metadata key absent from this payload is dropped. \`tags\`/\`links\` stay additive (same union behavior as \`"update"\`) — a replace targets the free-form fields, not the taxonomy/graph, so it can't silently orphan links or detach tags the caller didn't mention. The note's \`id\` and \`created_at\` are preserved either way.
|
|
1211
|
-
|
|
1212
|
-
A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` was one of \`"ignore"\`/\`"update"\`/\`"replace"\` — \`true\` when the collision branch fired, \`false\` when a normal fresh insert happened instead (including when \`path\` was never set, so there was nothing to conflict with — \`if_exists\` is a no-op without a \`path\`, but still reports \`existed: false\`). Absent entirely under the default \`"error"\` mode — a plain create-note call's response shape is byte-identical to before this feature. Batch-aware, per-item (like \`if_missing\` on \`update-note\`): set \`if_exists\` inside each \`notes[]\` entry — a top-level \`if_exists\` alongside a \`notes\` array is NOT inherited by items that omit their own (it only takes effect on the single-note form, where \`params\` IS the one item).
|
|
1213
|
-
|
|
1214
|
-
**Batch summary** — pass \`summary: true\` (batch/\`notes\` calls only; ignored on a single-note call) to receive a compact \`{created, ids, failed}\` shape instead of N full note objects: \`created\` counts items that resulted in a BRAND-NEW insert (excludes \`if_exists\` collisions); \`ids\` lists every resulting note id in item order (fresh creates AND \`existed\` hits alike); \`failed\` is reserved for future partial-batch-failure reporting — today a batch create is all-or-nothing (any thrown error aborts and rolls back the WHOLE call, same with or without \`summary\`), so it's always \`[]\`.`,
|
|
1215
|
-
inputSchema: {
|
|
1216
|
-
type: "object",
|
|
1217
|
-
properties: {
|
|
1218
|
-
// Single note fields
|
|
1219
|
-
content: { type: "string", description: "Note content (markdown). Wikilinks like [[Target]] auto-resolve." },
|
|
1220
|
-
path: { type: "string", description: "Note path (e.g., 'Projects/README')" },
|
|
1221
|
-
extension: { type: "string", description: "File extension (vault#328). Default \"md\". Use \"csv\"/\"yaml\"/\"json\"/\"mdx\"/etc. for non-markdown notes. Lowercase alphanumeric, 1–16 chars; no '.' or '/'. The \"parachute\" prefix is reserved." },
|
|
1222
|
-
metadata: { type: "object", description: "Metadata fields" },
|
|
1223
|
-
tags: { type: "array", items: { type: "string" }, description: "Tags to apply" },
|
|
1224
|
-
links: {
|
|
1225
|
-
type: "array",
|
|
1226
|
-
items: {
|
|
1227
|
-
type: "object",
|
|
1228
|
-
properties: {
|
|
1229
|
-
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
1230
|
-
relationship: { type: "string", description: "Relationship type (e.g., mentions, related-to)" },
|
|
1231
|
-
},
|
|
1232
|
-
required: ["target", "relationship"],
|
|
1233
|
-
},
|
|
1234
|
-
description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename, then (only on a clean miss, and only when exactly one note matches) an H1-title fallback. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
|
|
1235
|
-
},
|
|
1236
|
-
created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
|
|
1237
|
-
if_exists: {
|
|
1238
|
-
type: "string",
|
|
1239
|
-
enum: ["error", "ignore", "update", "replace"],
|
|
1240
|
-
description: "What to do when `path` already names an existing note (vault#555). See the tool description for the full contract of each mode. Default \"error\" — unchanged path_conflict behavior.",
|
|
1241
|
-
},
|
|
1242
|
-
summary: {
|
|
1243
|
-
type: "boolean",
|
|
1244
|
-
description: "Batch calls only (a `notes` array): return a compact `{created, ids, failed}` shape instead of N full note objects. See the tool description. Ignored on a single-note call.",
|
|
1245
|
-
},
|
|
1246
|
-
// Batch
|
|
1247
|
-
notes: {
|
|
1248
|
-
type: "array",
|
|
1249
|
-
items: {
|
|
1250
|
-
type: "object",
|
|
1251
|
-
properties: {
|
|
1252
|
-
content: { type: "string", description: "Optional — defaults to \"\" (vault#555 fix: this item's schema previously marked it `required`, but it was never enforced; an empty-content batch item has always succeeded)." },
|
|
1253
|
-
path: { type: "string" },
|
|
1254
|
-
extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
|
|
1255
|
-
metadata: { type: "object" },
|
|
1256
|
-
tags: { type: "array", items: { type: "string" } },
|
|
1257
|
-
links: { type: "array" },
|
|
1258
|
-
created_at: { type: "string" },
|
|
1259
|
-
if_exists: { type: "string", enum: ["error", "ignore", "update", "replace"], description: "Per-item: see top-level `if_exists` docs. Each batch item carries its own setting." },
|
|
1260
|
-
},
|
|
1261
|
-
},
|
|
1262
|
-
description: "Array of notes for batch creation",
|
|
1263
|
-
},
|
|
1264
|
-
},
|
|
1265
|
-
},
|
|
1266
1243
|
execute: async (params) => {
|
|
1267
1244
|
const batch = params.notes as any[] | undefined;
|
|
1268
1245
|
const items = batch ?? [params];
|
|
@@ -1651,144 +1628,6 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1651
1628
|
// =====================================================================
|
|
1652
1629
|
{
|
|
1653
1630
|
name: "update-note",
|
|
1654
|
-
requiredVerb: "write",
|
|
1655
|
-
description: `Update one or more notes. Accepts ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title. Supports content, path, metadata updates plus tag and link mutations.
|
|
1656
|
-
|
|
1657
|
-
- Three content-modification modes (mutually exclusive):
|
|
1658
|
-
- \`content\` — full replace.
|
|
1659
|
-
- \`append\` / \`prepend\` — atomic concatenation at the SQL layer. Multiple agents appending to the same note never overwrite each other. No separator is added; include trailing/leading whitespace yourself if needed. May be combined with each other.
|
|
1660
|
-
- \`content_edit: { old_text, new_text }\` — surgical find-and-replace. \`old_text\` must occur exactly once; zero or multiple matches return an error. Add surrounding context to disambiguate.
|
|
1661
|
-
- \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
|
|
1662
|
-
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
1663
|
-
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
1664
|
-
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
1665
|
-
- **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.
|
|
1666
|
-
- **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.
|
|
1667
|
-
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) 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. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
|
|
1668
|
-
|
|
1669
|
-
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\`.`,
|
|
1670
|
-
inputSchema: {
|
|
1671
|
-
type: "object",
|
|
1672
|
-
properties: {
|
|
1673
|
-
id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
1674
|
-
content: { type: "string", description: "New content (full replace). Mutually exclusive with `append`/`prepend` and `content_edit`." },
|
|
1675
|
-
append: { type: "string", description: "Text to append to the end of the note. Atomic at the SQL layer — concurrent appends are safe. Mutually exclusive with `content` and `content_edit`. No precondition required." },
|
|
1676
|
-
prepend: { type: "string", description: "Text to prepend to the start of the note. Atomic at the SQL layer. Mutually exclusive with `content` and `content_edit`. May combine with `append`. No precondition required." },
|
|
1677
|
-
content_edit: {
|
|
1678
|
-
type: "object",
|
|
1679
|
-
properties: {
|
|
1680
|
-
old_text: { type: "string", description: "Exact text to find. Must match exactly once in the note's current content." },
|
|
1681
|
-
new_text: { type: "string", description: "Replacement text." },
|
|
1682
|
-
},
|
|
1683
|
-
required: ["old_text", "new_text"],
|
|
1684
|
-
description: "Find-and-replace one occurrence. Errors if `old_text` is not found or matches multiple locations. Mutually exclusive with `content` and `append`/`prepend`.",
|
|
1685
|
-
},
|
|
1686
|
-
path: { type: "string", description: "New path" },
|
|
1687
|
-
extension: { type: "string", description: "Change the note's file extension (vault#328). Allowed but caller-owned — you're responsible for content validity if you switch a non-empty note's extension. Lowercase alphanumeric, 1–16 chars; \"parachute\" prefix reserved." },
|
|
1688
|
-
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale). A value of `null` deletes that key (RFC 7386 merge-patch) — e.g. `{\"new_key\": \"v\", \"old_key\": null}` renames in one call. Omitting a key preserves its existing value." },
|
|
1689
|
-
created_at: { type: "string", description: "New created_at timestamp" },
|
|
1690
|
-
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
1691
|
-
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
|
|
1692
|
-
if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
|
|
1693
|
-
state_transition: {
|
|
1694
|
-
type: "object",
|
|
1695
|
-
properties: {
|
|
1696
|
-
field: { type: "string", description: "Metadata field to transition." },
|
|
1697
|
-
from: { description: "Required current value. The transition only commits if the field currently equals this. A missing field is a conflict; pass `null` to match a field that is absent or explicitly null." },
|
|
1698
|
-
to: { description: "New value to set when the `from` precondition holds." },
|
|
1699
|
-
},
|
|
1700
|
-
required: ["field", "from", "to"],
|
|
1701
|
-
description: "Atomic compare-and-set state transition (vault#299). If the metadata `field` currently equals `from`, set it to `to` and commit; otherwise the write is rejected with a `transition_conflict` error (a missing field counts as a conflict; `from: null` matches absent-or-null). A transition-ONLY update needs no `if_updated_at`/`force` — the compare-and-set is the precondition. Combinable with other field updates (they land in the same atomic UPDATE), but a combined call still needs `if_updated_at`/`force` for the OTHER fields — the CAS only guards the transitioned field. Use this to advance a state machine race-safely in one round trip instead of read → check → conditional update.",
|
|
1702
|
-
},
|
|
1703
|
-
tags: {
|
|
1704
|
-
type: "object",
|
|
1705
|
-
properties: {
|
|
1706
|
-
add: { type: "array", items: { type: "string" } },
|
|
1707
|
-
remove: { type: "array", items: { type: "string" } },
|
|
1708
|
-
},
|
|
1709
|
-
description: "Tags to add/remove",
|
|
1710
|
-
},
|
|
1711
|
-
links: {
|
|
1712
|
-
type: "object",
|
|
1713
|
-
properties: {
|
|
1714
|
-
add: {
|
|
1715
|
-
type: "array",
|
|
1716
|
-
items: {
|
|
1717
|
-
type: "object",
|
|
1718
|
-
properties: {
|
|
1719
|
-
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
1720
|
-
relationship: { type: "string" },
|
|
1721
|
-
},
|
|
1722
|
-
required: ["target", "relationship"],
|
|
1723
|
-
},
|
|
1724
|
-
},
|
|
1725
|
-
remove: {
|
|
1726
|
-
type: "array",
|
|
1727
|
-
items: {
|
|
1728
|
-
type: "object",
|
|
1729
|
-
properties: {
|
|
1730
|
-
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
1731
|
-
relationship: { type: "string" },
|
|
1732
|
-
},
|
|
1733
|
-
required: ["target", "relationship"],
|
|
1734
|
-
},
|
|
1735
|
-
},
|
|
1736
|
-
},
|
|
1737
|
-
description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename, then (only on a clean miss, and only when exactly one note matches) an H1-title fallback — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
|
|
1738
|
-
},
|
|
1739
|
-
include_content: {
|
|
1740
|
-
type: "boolean",
|
|
1741
|
-
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`, a whitespace-collapsed `preview`, and a computed `displayTitle`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
|
|
1742
|
-
},
|
|
1743
|
-
include_links: {
|
|
1744
|
-
type: "boolean",
|
|
1745
|
-
description: "Echo the note's hydrated inbound + outbound links on the response (vault feedback #8). Links are *also* echoed automatically whenever the update itself mutated links (`links.add`/`links.remove`), so you rarely need to set this — its purpose is to fetch the current link set on an update that didn't touch links. Default: `false` (and absent from the response unless mutated or requested). Mirrors `query-notes`'s `include_links`. This top-level flag applies to the single-note form only; for a batch, set `include_links` on each note object in `notes` (a top-level `include_links` is ignored when `notes` is present).",
|
|
1746
|
-
},
|
|
1747
|
-
// Batch
|
|
1748
|
-
notes: {
|
|
1749
|
-
type: "array",
|
|
1750
|
-
items: {
|
|
1751
|
-
type: "object",
|
|
1752
|
-
properties: {
|
|
1753
|
-
id: { type: "string" },
|
|
1754
|
-
content: { type: "string" },
|
|
1755
|
-
append: { type: "string" },
|
|
1756
|
-
prepend: { type: "string" },
|
|
1757
|
-
content_edit: {
|
|
1758
|
-
type: "object",
|
|
1759
|
-
properties: {
|
|
1760
|
-
old_text: { type: "string" },
|
|
1761
|
-
new_text: { type: "string" },
|
|
1762
|
-
},
|
|
1763
|
-
required: ["old_text", "new_text"],
|
|
1764
|
-
},
|
|
1765
|
-
path: { type: "string" },
|
|
1766
|
-
extension: { type: "string", description: "Change the note's file extension (vault#328). See top-level docs." },
|
|
1767
|
-
metadata: { type: "object" },
|
|
1768
|
-
created_at: { type: "string" },
|
|
1769
|
-
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
1770
|
-
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` for this item. Does not override an `if_updated_at` you actually pass — a supplied precondition still applies and a mismatch conflicts." },
|
|
1771
|
-
if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
|
|
1772
|
-
state_transition: {
|
|
1773
|
-
type: "object",
|
|
1774
|
-
properties: {
|
|
1775
|
-
field: { type: "string" },
|
|
1776
|
-
from: {},
|
|
1777
|
-
to: {},
|
|
1778
|
-
},
|
|
1779
|
-
required: ["field", "from", "to"],
|
|
1780
|
-
description: "Per-item compare-and-set state transition (vault#299). See top-level `state_transition` docs.",
|
|
1781
|
-
},
|
|
1782
|
-
tags: { type: "object" },
|
|
1783
|
-
links: { type: "object" },
|
|
1784
|
-
include_links: { type: "boolean", description: "Per-item: echo hydrated links on this item's response (vault feedback #8). Also implied when this item mutates links." },
|
|
1785
|
-
},
|
|
1786
|
-
required: ["id"],
|
|
1787
|
-
},
|
|
1788
|
-
description: "Array of note updates for batch",
|
|
1789
|
-
},
|
|
1790
|
-
},
|
|
1791
|
-
},
|
|
1792
1631
|
execute: async (params) => {
|
|
1793
1632
|
const batch = params.notes as any[] | undefined;
|
|
1794
1633
|
// vault#554: top-level `force` / `if_updated_at` apply as per-item
|
|
@@ -2321,22 +2160,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2321
2160
|
// =====================================================================
|
|
2322
2161
|
{
|
|
2323
2162
|
name: "delete-note",
|
|
2324
|
-
// `write` — same destructive verb as update-note. Aaron's call
|
|
2325
|
-
// 2026-05-27: "delete- in write; right now the only admin gated
|
|
2326
|
-
// thing is tokens." Reserving `admin` for "operator-only
|
|
2327
|
-
// capabilities" (token mgmt + future config writes). A future
|
|
2328
|
-
// finer-grained model might split `vault:write:no-delete` for
|
|
2329
|
-
// genuinely append-only callers — gating WITHIN write rather
|
|
2330
|
-
// than promoting deletes out of it.
|
|
2331
|
-
requiredVerb: "write",
|
|
2332
|
-
description: "Permanently delete a note and all its tags and links. Accepts ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title.",
|
|
2333
|
-
inputSchema: {
|
|
2334
|
-
type: "object",
|
|
2335
|
-
properties: {
|
|
2336
|
-
id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
2337
|
-
},
|
|
2338
|
-
required: ["id"],
|
|
2339
|
-
},
|
|
2340
2163
|
execute: async (params) => {
|
|
2341
2164
|
const note = requireNote(db, params.id as string);
|
|
2342
2165
|
await store.deleteNote(note.id);
|
|
@@ -2349,15 +2172,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2349
2172
|
// =====================================================================
|
|
2350
2173
|
{
|
|
2351
2174
|
name: "list-tags",
|
|
2352
|
-
requiredVerb: "read",
|
|
2353
|
-
description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag. NOTE (vault#555): this list includes zero-membership tags (\`count: 0\` — a declared schema never yet applied, or a tag every note was since untagged from), so its length can run higher than \`vault-info\`'s stats \`tagCount\`, which counts only tags at least one note currently carries.`,
|
|
2354
|
-
inputSchema: {
|
|
2355
|
-
type: "object",
|
|
2356
|
-
properties: {
|
|
2357
|
-
tag: { type: "string", description: "Get details for a single tag" },
|
|
2358
|
-
include_schema: { type: "boolean", description: "Include full tag record (description, fields, relationships, parent_names, timestamps) for each tag (default: false)" },
|
|
2359
|
-
},
|
|
2360
|
-
},
|
|
2361
2175
|
execute: (params) => {
|
|
2362
2176
|
const singleTag = params.tag as string | undefined;
|
|
2363
2177
|
|
|
@@ -2425,51 +2239,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2425
2239
|
// =====================================================================
|
|
2426
2240
|
{
|
|
2427
2241
|
name: "update-tag",
|
|
2428
|
-
// `admin` (was `write`) — this PR: update-tag defines a tag's SCHEMA
|
|
2429
|
-
// (description, indexed-field types, relationship vocabulary,
|
|
2430
|
-
// hierarchy parents), which every note carrying the tag inherits.
|
|
2431
|
-
// That's structure/taxonomy curation, not content authorship — the
|
|
2432
|
-
// same distinction that keeps content out of admin and structure out
|
|
2433
|
-
// of write. See the `generateMcpTools` doc comment above for the full
|
|
2434
|
-
// re-tier rationale + BREAKING note.
|
|
2435
|
-
requiredVerb: "admin",
|
|
2436
|
-
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, 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-vault/docs/contracts/tag-data-model.md.",
|
|
2437
|
-
inputSchema: {
|
|
2438
|
-
type: "object",
|
|
2439
|
-
properties: {
|
|
2440
|
-
tag: { type: "string", description: "Tag name" },
|
|
2441
|
-
description: { type: "string", description: "Human-readable description of what this tag means" },
|
|
2442
|
-
fields: {
|
|
2443
|
-
type: "object",
|
|
2444
|
-
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true, "default": "active" } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299). Mark a field `indexed: true` to make it queryable — an indexed field\'s TYPE is ALWAYS enforced (a type-mismatched write is REJECTED, independent of `strict`) because a bad-typed value silently poisons range-query ordering (vault#553).',
|
|
2445
|
-
additionalProperties: {
|
|
2446
|
-
type: "object",
|
|
2447
|
-
properties: {
|
|
2448
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference, date — all eight are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference/date are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`. `date` stores/validates exactly like `string`, but the value must be an ISO-8601 date (`2026-07-09`) or full timestamp (`2026-07-09T00:00:00.000Z`) — an unparseable value is a type_mismatch (advisory) or a rejected write (`strict: true` / `indexed: true`), same treatment as any other type mismatch. A full timestamp carrying an explicit `±HH:MM` offset is normalized to canonical UTC (`Z`-suffixed) on write — the offset is accepted, but not persisted verbatim — so indexed `date` fields sort/filter correctly under the TEXT comparison `gt`/`gte`/`lt`/`lte`/`date_filter`/`order_by` all use; a bare date (no time component) is left as-is." },
|
|
2449
|
-
description: { type: "string" },
|
|
2450
|
-
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
2451
|
-
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
|
2452
|
-
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference/date are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
2453
|
-
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
|
|
2454
|
-
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
2455
|
-
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
2456
|
-
},
|
|
2457
|
-
required: ["type"],
|
|
2458
|
-
},
|
|
2459
|
-
},
|
|
2460
|
-
relationships: {
|
|
2461
|
-
type: "object",
|
|
2462
|
-
description: 'Opaque relationship-vocabulary map: keys are relationship names, values are arbitrary JSON the declaring app interprets. Vault stores and returns the values verbatim and does NOT enforce any inner shape — only that this is a JSON object (a map), not an array or primitive. Replaces any prior map wholesale when provided; pass null to clear. The historical typed shape { "lives_in": { "target_tag": "place", "cardinality": "one" } } is still a valid value, as is any app-defined shape e.g. { "works-on": { "from": "person", "to": "project" } }.',
|
|
2463
|
-
additionalProperties: true,
|
|
2464
|
-
},
|
|
2465
|
-
parent_names: {
|
|
2466
|
-
type: "array",
|
|
2467
|
-
items: { type: "string" },
|
|
2468
|
-
description: "Tag names this tag is a child of, for the query-time hierarchy. Replaces any prior parent list. Pass [] (empty array) or null to clear. E.g., parent_names: [\"manual\", \"note\"] makes this tag a descendant of both.",
|
|
2469
|
-
},
|
|
2470
|
-
},
|
|
2471
|
-
required: ["tag"],
|
|
2472
|
-
},
|
|
2473
2242
|
execute: async (params) => {
|
|
2474
2243
|
// Canonical-bare-tag guard (PR #516): normalize the tag NAME up front
|
|
2475
2244
|
// so the existing-record lookup (and the field/cross-tag merge that
|
|
@@ -2568,25 +2337,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2568
2337
|
// =====================================================================
|
|
2569
2338
|
{
|
|
2570
2339
|
name: "delete-tag",
|
|
2571
|
-
// `admin` (was `write` — Aaron's 2026-05-27 call reserved admin for
|
|
2572
|
-
// token mgmt + future config writes; deletes were write-tier
|
|
2573
|
-
// mutations, see delete-note's rationale). Superseded by this PR:
|
|
2574
|
-
// delete-tag removes a tag's identity row + schema and untags it
|
|
2575
|
-
// vault-wide — that's structure/taxonomy curation, the same class as
|
|
2576
|
-
// update-tag/rename-tag/merge-tags, not content authorship. See the
|
|
2577
|
-
// `generateMcpTools` doc comment above for the full re-tier rationale
|
|
2578
|
-
// + BREAKING note.
|
|
2579
|
-
requiredVerb: "admin",
|
|
2580
|
-
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway. Also refused with error_type \"tag_in_use_by_tokens\" (vault#555 fix — this case existed pre-#555 but was undocumented here; see \"merge-tags\" for the identical guard) when the tag is referenced by a tag-scoped token's allowlist — revoke or re-mint the token(s) first. A no-op on a tag with no identity row and no notes returns {deleted: false, notes_untagged: 0} rather than erroring.",
|
|
2581
|
-
inputSchema: {
|
|
2582
|
-
type: "object",
|
|
2583
|
-
properties: {
|
|
2584
|
-
tag: { type: "string", description: "Tag name to delete" },
|
|
2585
|
-
cascade: { type: "boolean", description: "Proceed even though another tag's parent_names references this one, stripping the reference. Synonym of detach." },
|
|
2586
|
-
detach: { type: "boolean", description: "Same as cascade — proceed and strip the stale parent_names reference from referencing tag(s)." },
|
|
2587
|
-
},
|
|
2588
|
-
required: ["tag"],
|
|
2589
|
-
},
|
|
2590
2340
|
execute: async (params) => {
|
|
2591
2341
|
const tag = params.tag as string;
|
|
2592
2342
|
// Drop the row outright — description/fields/relationships/parents
|
|
@@ -2611,25 +2361,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2611
2361
|
// =====================================================================
|
|
2612
2362
|
{
|
|
2613
2363
|
name: "rename-tag",
|
|
2614
|
-
// `admin` (was `write`) — this PR: an atomic cascading rename across
|
|
2615
|
-
// note memberships, other tags' parent_names, tokens' allowlists,
|
|
2616
|
-
// indexed-field declarer lists, and inline #tag mentions is structural
|
|
2617
|
-
// taxonomy surgery, not content authorship. Same tier as
|
|
2618
|
-
// update-tag/delete-tag/merge-tags. See the `generateMcpTools` doc
|
|
2619
|
-
// comment above for the full re-tier rationale + BREAKING note.
|
|
2620
|
-
requiredVerb: "admin",
|
|
2621
|
-
description:
|
|
2622
|
-
"Atomically rename a tag across EVERY surface that references it: note memberships, OTHER tags' parent_names, tag-scoped tokens' allowlists, indexed-field declarer lists, inline #tag mentions in note bodies, and _tags/<name> config-note paths — all in one transaction. THIS is the fix for the manual retag→delete dance (create the new tag, retag notes, delete the old one): that dance silently orphans parent_names references (the renamed-away tag stays a live query surface via subtype expansion while list-tags reports it at count 0, and the new tag misses every child-tagged note) and leaves stale #tag mentions behind. Sub-tags rename recursively — renaming \"task\" to \"todo\" also renames \"task/work\" to \"todo/work\". Does NOT rewrite metadata values that happen to equal the old tag name (e.g. metadata.epic: \"task\") — that's a distinct drift class the doctor tool's dead_tag_metadata_reference finding flags heuristically; rename-tag's job is structural (tags/note_tags/parent_names/tokens/content), not a blind string search-and-replace over arbitrary metadata.",
|
|
2623
|
-
inputSchema: {
|
|
2624
|
-
type: "object",
|
|
2625
|
-
properties: {
|
|
2626
|
-
old_name: { type: "string", description: "The tag to rename. Aliases: from, tag." },
|
|
2627
|
-
new_name: { type: "string", description: "The new name. Alias: to." },
|
|
2628
|
-
from: { type: "string", description: "Alias for old_name." },
|
|
2629
|
-
to: { type: "string", description: "Alias for new_name." },
|
|
2630
|
-
tag: { type: "string", description: "Alias for old_name." },
|
|
2631
|
-
},
|
|
2632
|
-
},
|
|
2633
2364
|
execute: async (params) => {
|
|
2634
2365
|
const oldName = (params.old_name ?? params.from ?? params.tag) as string | undefined;
|
|
2635
2366
|
const newName = (params.new_name ?? params.to) as string | undefined;
|
|
@@ -2674,22 +2405,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2674
2405
|
// =====================================================================
|
|
2675
2406
|
{
|
|
2676
2407
|
name: "merge-tags",
|
|
2677
|
-
// `admin` (was `write`) — this PR: merging N source tags into a
|
|
2678
|
-
// target (retagging every note, dropping the sources' identity rows)
|
|
2679
|
-
// is structural taxonomy surgery, not content authorship. Same tier
|
|
2680
|
-
// as update-tag/delete-tag/rename-tag. See the `generateMcpTools` doc
|
|
2681
|
-
// comment above for the full re-tier rationale + BREAKING note.
|
|
2682
|
-
requiredVerb: "admin",
|
|
2683
|
-
description:
|
|
2684
|
-
"Atomically merge one or more source tags into a target tag: every note carrying any source is retagged with the target, then the source tags (and their identity rows — description/fields/relationships/parent_names) are dropped. target is created if it doesn't exist yet; target's own schema is preserved (sources' schemas are consumed, not merged field-by-field). Sources that don't exist are reported at count 0. Refused with error_type \"tag_in_use_by_tokens\" if a source is referenced by a tag-scoped token — revoke or re-mint it first.",
|
|
2685
|
-
inputSchema: {
|
|
2686
|
-
type: "object",
|
|
2687
|
-
properties: {
|
|
2688
|
-
sources: { type: "array", items: { type: "string" }, description: "Tag names to merge away into target." },
|
|
2689
|
-
target: { type: "string", description: "The tag that survives; sources are retagged onto it and dropped." },
|
|
2690
|
-
},
|
|
2691
|
-
required: ["sources", "target"],
|
|
2692
|
-
},
|
|
2693
2408
|
execute: async (params) => {
|
|
2694
2409
|
const sources = params.sources;
|
|
2695
2410
|
const target = params.target;
|
|
@@ -2714,17 +2429,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2714
2429
|
// =====================================================================
|
|
2715
2430
|
{
|
|
2716
2431
|
name: "find-path",
|
|
2717
|
-
requiredVerb: "read",
|
|
2718
|
-
description: "Find the shortest path between two notes in the link graph. Accepts IDs, paths, or (fallback, only when id/path both miss and exactly one note matches) H1 titles. Returns null if no path exists, else `{path, relationships, nodes, edges}`: `path` (note IDs, source→target) and `relationships` (relationships[i] connects path[i] to path[i+1]) are the original id-only shape; `nodes` (vault#550, additive) hydrates each id in `path` with the note's own `path` field — `[{id, path}]` in the same order; `edges` (additive) is the self-contained hop list — `[{source, target, relationship, sourcePath, targetPath}]` — for rendering the chain without cross-referencing `nodes`.",
|
|
2719
|
-
inputSchema: {
|
|
2720
|
-
type: "object",
|
|
2721
|
-
properties: {
|
|
2722
|
-
source: { type: "string", description: "Starting note ID, path, or (fallback) H1 title" },
|
|
2723
|
-
target: { type: "string", description: "Destination note ID, path, or (fallback) H1 title" },
|
|
2724
|
-
max_depth: { type: "number", description: "Max path length (default 5)" },
|
|
2725
|
-
},
|
|
2726
|
-
required: ["source", "target"],
|
|
2727
|
-
},
|
|
2728
2432
|
execute: (params) => {
|
|
2729
2433
|
const source = requireNote(db, params.source as string);
|
|
2730
2434
|
const target = requireNote(db, params.target as string);
|
|
@@ -2739,24 +2443,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2739
2443
|
// =====================================================================
|
|
2740
2444
|
{
|
|
2741
2445
|
name: "vault-info",
|
|
2742
|
-
// `read` so vault:read callers can fetch stats. The
|
|
2743
|
-
// description-update branch performs an inner ADMIN-check (see
|
|
2744
|
-
// overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
|
|
2745
|
-
// `admin` or read-only callers lose the stats projection. Was an
|
|
2746
|
-
// inner write-check pre-this-PR; writing the vault's own
|
|
2747
|
-
// description/config is curation, not content, so it moved to the
|
|
2748
|
-
// same admin tier as the other structure-curation tools (update-tag
|
|
2749
|
-
// et al) — see the `generateMcpTools` doc comment above.
|
|
2750
|
-
requiredVerb: "read",
|
|
2751
|
-
description: "Get a comprehensive vault projection: name, description, `coordinates` (this vault's own REST/MCP URL templates — `{name, base_url, rest_api, mcp}`, always present), tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, query hints, `map` (front-door structural orientation, always present — see below), and (when a seeded onboarding guide exists) a `getting_started` note pointer. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution as a `stats` field. Pass `description` to update the vault description (changes how AI agents behave in future sessions) — requires the `vault:admin` scope for this vault even though the tool itself is read-gated (vault#555 originally required `vault:write` here; a later PR tightened it to `vault:admin` since a description edit is curation, not content — a `vault:read`-or-`vault:write`-only caller passing `description` gets a `Forbidden` rejection, not a silent no-op). Call this anytime mid-session to refresh schema context. NOTE (vault#555): the stats `tagCount` counts only tags at least one note currently carries (`COUNT(DISTINCT tag_name)` over note-tag memberships) — `list-tags`'s row count can run higher because it also lists zero-membership tags (an identity row from a declared schema or a since-untagged tag). Neither is wrong; they answer different questions. `map` — `{ total_notes, tags: [{name, count}], path_buckets: [{name, count}], unfiled_notes }` — is a compact, counts-only structural rollup (no content) meant to orient a fresh reader in this ONE call, no `include_stats` needed: every tag currently in use with its membership count, and every top-level path segment (the text before the first `/`) with how many notes live under it, plus how many notes carry no path at all. For a tag-scoped token, `map.tags`/`map.path_buckets`/`map.total_notes`/`map.unfiled_notes` cover only notes reachable through an in-scope tag — same confidentiality posture as the `tags`/`indexed_fields` catalogs above.",
|
|
2752
|
-
inputSchema: {
|
|
2753
|
-
type: "object",
|
|
2754
|
-
properties: {
|
|
2755
|
-
include_stats: { type: "boolean", description: "Include note count, tag count, attachment/link counts, and the monthly note distribution (default: false)" },
|
|
2756
|
-
description: { type: "string", description: "If provided, updates the vault description" },
|
|
2757
|
-
},
|
|
2758
|
-
},
|
|
2759
|
-
// execute is overridden in mcp-tools.ts where vault config is available
|
|
2760
2446
|
execute: () => {
|
|
2761
2447
|
// This is a placeholder — vault-info needs access to vault config,
|
|
2762
2448
|
// which is only available in the server layer (mcp-tools.ts).
|
|
@@ -2769,20 +2455,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2769
2455
|
// =====================================================================
|
|
2770
2456
|
{
|
|
2771
2457
|
name: "prune-schema",
|
|
2772
|
-
// `admin` — a destructive schema-maintenance op, same tier as
|
|
2773
|
-
// manage-token. Operator-only; hidden from read/write sessions.
|
|
2774
|
-
requiredVerb: "admin",
|
|
2775
|
-
description:
|
|
2776
|
-
"Drop orphaned indexed-field columns + indexes whose declaring tags no longer exist (the result of a deleted tag never releasing its fields). Dry-run by default — returns the drop plan without mutating. Pass `apply: true` to execute. A field co-declared by a still-live tag is never dropped; only the dead declarers are trimmed from its set. Generated columns are derived from notes.metadata JSON, so a drop loses only the index, never source data — declare the field again to rebuild it.",
|
|
2777
|
-
inputSchema: {
|
|
2778
|
-
type: "object",
|
|
2779
|
-
properties: {
|
|
2780
|
-
apply: {
|
|
2781
|
-
type: "boolean",
|
|
2782
|
-
description: "Execute the prune. Default false (dry-run — report what would be dropped without changing anything).",
|
|
2783
|
-
},
|
|
2784
|
-
},
|
|
2785
|
-
},
|
|
2786
2458
|
execute: async (params) => {
|
|
2787
2459
|
const apply = params.apply === true;
|
|
2788
2460
|
const plan = await store.pruneIndexedFields({ dryRun: !apply });
|
|
@@ -2804,20 +2476,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2804
2476
|
// =====================================================================
|
|
2805
2477
|
{
|
|
2806
2478
|
name: "doctor",
|
|
2807
|
-
// `read` (was `admin` — the original reasoning: same tier as
|
|
2808
|
-
// prune-schema, a diagnostic over the WHOLE vault's taxonomy, not
|
|
2809
|
-
// scoped to any one tag's write authority). Superseded by this PR:
|
|
2810
|
-
// doctor never mutates and is ALREADY tag-scope-restricted at the MCP
|
|
2811
|
-
// layer (see `applyTagScopeWrappers`'s `doctor` wrapper in
|
|
2812
|
-
// src/mcp-tools.ts, which re-runs the scan against the caller's
|
|
2813
|
-
// allowlist) — it's a read, not a curation op, and read-scoped
|
|
2814
|
-
// monitoring/tending jobs need to be able to run it without an admin
|
|
2815
|
-
// credential. The REST `GET /api/doctor` endpoint (routing.ts) is
|
|
2816
|
-
// re-tiered to `read` too, so both doors agree — no MCP/REST divergence.
|
|
2817
|
-
requiredVerb: "read",
|
|
2818
|
-
description:
|
|
2819
|
-
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Returns {findings, summary, scanned_at} — findings is an array, each entry {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
|
|
2820
|
-
inputSchema: { type: "object", properties: {} },
|
|
2821
2479
|
execute: async () => {
|
|
2822
2480
|
return await store.doctor();
|
|
2823
2481
|
},
|
|
@@ -2836,26 +2494,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2836
2494
|
// =====================================================================
|
|
2837
2495
|
const ticketSeam = opts?.attachmentTickets;
|
|
2838
2496
|
if (ticketSeam) {
|
|
2839
|
-
|
|
2497
|
+
executorDefs.push(
|
|
2840
2498
|
{
|
|
2841
2499
|
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
2500
|
execute: async (params) => {
|
|
2860
2501
|
const noteRef = params.note;
|
|
2861
2502
|
if (typeof noteRef !== "string" || noteRef.trim() === "") {
|
|
@@ -2947,16 +2588,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2947
2588
|
},
|
|
2948
2589
|
{
|
|
2949
2590
|
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
2591
|
execute: async (params) => {
|
|
2961
2592
|
const attachmentId = params.attachment_id;
|
|
2962
2593
|
if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
|
|
@@ -3019,6 +2650,125 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
3019
2650
|
);
|
|
3020
2651
|
}
|
|
3021
2652
|
|
|
2653
|
+
// =====================================================================
|
|
2654
|
+
// 14. read-attachment — model-lane byte reads (Wave 2). Present ONLY
|
|
2655
|
+
// when the server layer wires an AttachmentBytesProvider — see
|
|
2656
|
+
// `GenerateMcpToolsOpts.attachmentBytes`'s doc comment (D10). Dispatches
|
|
2657
|
+
// by mime family: text ranges come back as `content`/`content_offset`/
|
|
2658
|
+
// `content_total_length`/`content_next_offset` (the exact query-notes
|
|
2659
|
+
// pagination contract); images come back as a real MCP image block (see
|
|
2660
|
+
// this tool's `resultContent`); audio/video never send bytes — a
|
|
2661
|
+
// transcript pointer instead; PDF/other binary refuse with a
|
|
2662
|
+
// download-ticket pointer. Unlike the ticket tools, bytes (or a base64
|
|
2663
|
+
// encoding of them) DO pass through this tool — that's the whole point
|
|
2664
|
+
// of the model lane.
|
|
2665
|
+
// =====================================================================
|
|
2666
|
+
const bytesSeam = opts?.attachmentBytes;
|
|
2667
|
+
if (bytesSeam) {
|
|
2668
|
+
executorDefs.push({
|
|
2669
|
+
name: "read-attachment",
|
|
2670
|
+
execute: async (params) => {
|
|
2671
|
+
const attachmentId = params.attachment_id;
|
|
2672
|
+
if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
|
|
2673
|
+
throw structuredError("`attachment_id` is required", {
|
|
2674
|
+
error_type: "missing_required_field",
|
|
2675
|
+
field: "attachment_id",
|
|
2676
|
+
how_to: "pass the attachment id from a note's attachment rows",
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
const attachment = await store.getAttachment(attachmentId);
|
|
2681
|
+
if (!attachment) {
|
|
2682
|
+
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
2683
|
+
error_type: "not_found",
|
|
2684
|
+
field: "attachment_id",
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
if (bytesSeam.noteVisible) {
|
|
2689
|
+
const owningNote = await store.getNote(attachment.noteId);
|
|
2690
|
+
// Same uniform not_found posture as the ticket tools — a
|
|
2691
|
+
// tag-scoped caller learns nothing about an out-of-scope note's
|
|
2692
|
+
// existence via a differential error.
|
|
2693
|
+
if (!owningNote || !(await bytesSeam.noteVisible(owningNote))) {
|
|
2694
|
+
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
2695
|
+
error_type: "not_found",
|
|
2696
|
+
field: "attachment_id",
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
const mimeType = effectiveAttachmentMime(attachment);
|
|
2702
|
+
|
|
2703
|
+
if (isImageMime(mimeType)) {
|
|
2704
|
+
if (params.content_offset !== undefined || params.content_length !== undefined) {
|
|
2705
|
+
throw structuredError("content_offset/content_length don't apply to image attachments", {
|
|
2706
|
+
error_type: "invalid_query",
|
|
2707
|
+
field: "content_offset",
|
|
2708
|
+
hint: "omit content_offset/content_length for an image read — the whole image (up to the 4 MiB cap) comes back in one call",
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
return await readImageAttachment(attachment, mimeType, bytesSeam.provider);
|
|
2712
|
+
}
|
|
2713
|
+
if (isTextMime(mimeType)) {
|
|
2714
|
+
return await readTextAttachment(attachment, mimeType, params, bytesSeam.provider);
|
|
2715
|
+
}
|
|
2716
|
+
if (isAudioOrVideoMime(mimeType)) {
|
|
2717
|
+
return await readAudioPointer(attachment, bytesSeam.provider);
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
// Other binary (PDF, zip, docx, ...) — refuse honestly rather than
|
|
2721
|
+
// returning garbage or truncated bytes; extraction is a v2 concern
|
|
2722
|
+
// (D5). Still stats first so a row whose bytes are ALSO gone gets
|
|
2723
|
+
// the more accurate attachment_binary_missing instead.
|
|
2724
|
+
const stat = await statAttachmentOrMissing(attachment, bytesSeam.provider);
|
|
2725
|
+
throw structuredError(`Attachment type "${mimeType}" isn't directly readable by this tool`, {
|
|
2726
|
+
error_type: "unsupported_attachment_type",
|
|
2727
|
+
mime_type: mimeType,
|
|
2728
|
+
size: stat.size,
|
|
2729
|
+
how_to: "mint a download ticket with request-attachment-download and process the file locally",
|
|
2730
|
+
});
|
|
2731
|
+
},
|
|
2732
|
+
resultContent: (result) => {
|
|
2733
|
+
const r = result as ({ _mcpImage?: { data: string; mimeType: string } } & Record<string, unknown>) | null;
|
|
2734
|
+
if (r && r._mcpImage) {
|
|
2735
|
+
const { _mcpImage, ...rest } = r;
|
|
2736
|
+
return [
|
|
2737
|
+
{ type: "text", text: JSON.stringify(rest, null, 2) },
|
|
2738
|
+
{ type: "image", data: _mcpImage.data, mimeType: _mcpImage.mimeType },
|
|
2739
|
+
];
|
|
2740
|
+
}
|
|
2741
|
+
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
2742
|
+
},
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// Zip each manifest entry (the single source of name/description/
|
|
2747
|
+
// inputSchema/requiredVerb + inclusion condition) with its store-bound
|
|
2748
|
+
// executor, in manifest order. A conditional tool whose seam wasn't wired is
|
|
2749
|
+
// skipped BEFORE its executor is looked up — matching the pre-manifest
|
|
2750
|
+
// "omitted when unwired" posture exactly. The emitted set is byte-identical
|
|
2751
|
+
// to the pre-refactor tool literals (pinned by mcp-manifest.test.ts).
|
|
2752
|
+
const executorsByName = new Map(executorDefs.map((e) => [e.name, e]));
|
|
2753
|
+
const tools: McpToolDef[] = [];
|
|
2754
|
+
for (const entry of MCP_TOOL_MANIFEST) {
|
|
2755
|
+
if (entry.condition === "attachment-tickets" && !ticketSeam) continue;
|
|
2756
|
+
if (entry.condition === "attachment-bytes" && !bytesSeam) continue;
|
|
2757
|
+
const impl = executorsByName.get(entry.name);
|
|
2758
|
+
if (!impl) {
|
|
2759
|
+
// A core (or wired-seam) tool with no executor is a manifest/impl drift
|
|
2760
|
+
// bug — fail loudly rather than silently drop a tool.
|
|
2761
|
+
throw new Error(`generateMcpTools: no executor registered for MCP tool "${entry.name}"`);
|
|
2762
|
+
}
|
|
2763
|
+
tools.push({
|
|
2764
|
+
name: entry.name,
|
|
2765
|
+
description: entry.description,
|
|
2766
|
+
inputSchema: entry.inputSchema,
|
|
2767
|
+
requiredVerb: entry.requiredVerb,
|
|
2768
|
+
execute: impl.execute,
|
|
2769
|
+
...(impl.resultContent ? { resultContent: impl.resultContent } : {}),
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
3022
2772
|
return tools;
|
|
3023
2773
|
}
|
|
3024
2774
|
|