@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/src/mcp.ts CHANGED
@@ -45,8 +45,8 @@ import {
45
45
  contentRangeRequiresContent,
46
46
  parseAttachmentContentRange,
47
47
  alignByteWindow,
48
- MIN_CONTENT_LENGTH,
49
48
  } from "./content-range.js";
49
+ import { MCP_TOOL_MANIFEST } from "./mcp-manifest.js";
50
50
  import {
51
51
  BLOCKED_ATTACHMENT_EXTENSIONS,
52
52
  ATTACHMENT_MIME_TYPES,
@@ -108,6 +108,20 @@ export interface McpToolDef {
108
108
  resultContent?: (result: unknown) => McpContentBlock[];
109
109
  }
110
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[];
123
+ }
124
+
111
125
  // ---------------------------------------------------------------------------
112
126
  // Helpers
113
127
  // ---------------------------------------------------------------------------
@@ -573,207 +587,17 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
573
587
  });
574
588
  };
575
589
 
576
- const tools: McpToolDef[] = [
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[] = [
577
595
 
578
596
  // =====================================================================
579
597
  // 1. query-notes — the universal read tool
580
598
  // =====================================================================
581
599
  {
582
600
  name: "query-notes",
583
- requiredVerb: "read",
584
- description: `Query notes. Returns notes matching the given filters.
585
-
586
- - **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")
587
- - **Filter**: pass \`tag\`, \`path\`, \`path_prefix\`, \`search\`, \`metadata\`, date range
588
- - **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
589
- - **No filters**: returns all notes (paginated)
590
-
591
- Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
592
-
593
- 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.
594
-
595
- 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.
596
-
597
- 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.
598
-
599
- 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\`.
600
-
601
- Response shape (vault#550 — three variants, pick by what you passed):
602
- - Default (no \`cursor\`, no warnings): a bare array of notes.
603
- - Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
604
- - 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.
605
- - \`aggregate\` mode: \`[{group, value}]\` — a rollup row per group, NOT notes. See \`aggregate\` below.
606
-
607
- \`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\`.
608
-
609
- \`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.
610
-
611
- \`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).`,
612
- inputSchema: {
613
- type: "object",
614
- properties: {
615
- 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" },
616
- tag: {
617
- oneOf: [
618
- { type: "string" },
619
- { type: "array", items: { type: "string" } },
620
- ],
621
- description: "Filter by tag(s)",
622
- },
623
- tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
624
- expand: {
625
- type: "string",
626
- enum: ["subtypes", "namespace", "both", "exact"],
627
- 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).",
628
- },
629
- exclude_tags: {
630
- oneOf: [
631
- { type: "string" },
632
- { type: "array", items: { type: "string" } },
633
- ],
634
- 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`).",
635
- },
636
- // The runtime alias-fallback chain accepts these too. Declared
637
- // here so schema-introspecting clients (Claude, MCP clients
638
- // that surface tool schemas) see them as valid inputs rather
639
- // than thinking the canonical is the only option.
640
- excludeTags: {
641
- oneOf: [
642
- { type: "string" },
643
- { type: "array", items: { type: "string" } },
644
- ],
645
- description: "Alias for `exclude_tags` (camelCase). Same shape and semantics — pick whichever is more natural for your client.",
646
- },
647
- exclude_tag: {
648
- oneOf: [
649
- { type: "string" },
650
- { type: "array", items: { type: "string" } },
651
- ],
652
- description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
653
- },
654
- has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
655
- 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)." },
656
- 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)." },
657
- path: { type: "string", description: "Exact path match (case-insensitive)" },
658
- path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
659
- extension: {
660
- oneOf: [
661
- { type: "string" },
662
- { type: "array", items: { type: "string" } },
663
- ],
664
- 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.",
665
- },
666
- search: {
667
- type: "string",
668
- description:
669
- '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).',
670
- },
671
- search_mode: {
672
- type: "string",
673
- enum: [...SEARCH_MODES],
674
- description:
675
- '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").',
676
- },
677
- near_text: {
678
- type: "string",
679
- description:
680
- '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.',
681
- },
682
- semantic: {
683
- type: "boolean",
684
- description:
685
- '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.',
686
- },
687
- metadata: {
688
- type: "object",
689
- 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.",
690
- },
691
- 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." },
692
- 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." },
693
- 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." },
694
- 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." },
695
- 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." },
696
- date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
697
- date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
698
- date_filter: {
699
- type: "object",
700
- properties: {
701
- 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`." },
702
- from: { type: "string", description: "Inclusive lower bound (ISO date)." },
703
- to: { type: "string", description: "Exclusive upper bound (ISO date)." },
704
- },
705
- 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.",
706
- },
707
- aggregate: {
708
- type: "object",
709
- properties: {
710
- 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)." },
711
- op: { type: "string", enum: ["count", "sum"], description: "\"count\": number of matching notes per group. \"sum\": sum of `field` per group." },
712
- 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)." },
713
- },
714
- required: ["group_by", "op"],
715
- 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.",
716
- },
717
- near: {
718
- type: "object",
719
- properties: {
720
- note_id: { type: "string", description: "Anchor note ID, path, or (fallback) H1 title" },
721
- depth: { type: "number", description: "Max hops from anchor (default 2, max 5)" },
722
- relationship: { type: "string", description: "Only follow links with this relationship" },
723
- },
724
- required: ["note_id"],
725
- description: "Scope results to notes within N hops of an anchor note",
726
- },
727
- sort: {
728
- type: "string",
729
- enum: ["asc", "desc"],
730
- description:
731
- '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.',
732
- },
733
- limit: { type: "number", description: "Max results (default 50)" },
734
- offset: { type: "number", description: "Pagination offset (default 0)" },
735
- cursor: {
736
- type: "string",
737
- description:
738
- "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\"`.",
739
- },
740
- include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
741
- content_offset: {
742
- type: "number",
743
- description:
744
- "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).",
745
- },
746
- content_length: {
747
- type: "number",
748
- description:
749
- `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.`,
750
- },
751
- include_metadata: {
752
- oneOf: [
753
- { type: "boolean" },
754
- { type: "array", items: { type: "string" } },
755
- ],
756
- description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
757
- },
758
- include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
759
- 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`." },
760
- include_link_count: {
761
- type: "boolean",
762
- description:
763
- "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.",
764
- },
765
- link_count_direction: {
766
- type: "string",
767
- enum: ["both", "outbound", "inbound"],
768
- description:
769
- "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.",
770
- },
771
- include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
772
- expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
773
- expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
774
- expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines metadata.summary — falling back to the note's opening paragraph when no metadata.summary exists. Default: 'full'." },
775
- },
776
- },
777
601
  execute: async (params) => {
778
602
  // --- Link expansion config (shared across single + list paths) ---
779
603
  const expandLinks = params.expand_links === true;
@@ -1416,69 +1240,6 @@ Response shape (vault#550 — three variants, pick by what you passed):
1416
1240
  // =====================================================================
1417
1241
  {
1418
1242
  name: "create-note",
1419
- requiredVerb: "write",
1420
- 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.
1421
-
1422
- **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.
1423
- - \`"error"\` (DEFAULT — unchanged behavior): the write is rejected with a \`path_conflict\` error (409); nothing is mutated.
1424
- - \`"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.
1425
- - \`"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\`.
1426
- - \`"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.
1427
-
1428
- 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).
1429
-
1430
- **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 \`[]\`.`,
1431
- inputSchema: {
1432
- type: "object",
1433
- properties: {
1434
- // Single note fields
1435
- content: { type: "string", description: "Note content (markdown). Wikilinks like [[Target]] auto-resolve." },
1436
- path: { type: "string", description: "Note path (e.g., 'Projects/README')" },
1437
- 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." },
1438
- metadata: { type: "object", description: "Metadata fields" },
1439
- tags: { type: "array", items: { type: "string" }, description: "Tags to apply" },
1440
- links: {
1441
- type: "array",
1442
- items: {
1443
- type: "object",
1444
- properties: {
1445
- target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
1446
- relationship: { type: "string", description: "Relationship type (e.g., mentions, related-to)" },
1447
- },
1448
- required: ["target", "relationship"],
1449
- },
1450
- 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.",
1451
- },
1452
- created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
1453
- if_exists: {
1454
- type: "string",
1455
- enum: ["error", "ignore", "update", "replace"],
1456
- 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.",
1457
- },
1458
- summary: {
1459
- type: "boolean",
1460
- 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.",
1461
- },
1462
- // Batch
1463
- notes: {
1464
- type: "array",
1465
- items: {
1466
- type: "object",
1467
- properties: {
1468
- 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)." },
1469
- path: { type: "string" },
1470
- extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
1471
- metadata: { type: "object" },
1472
- tags: { type: "array", items: { type: "string" } },
1473
- links: { type: "array" },
1474
- created_at: { type: "string" },
1475
- 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." },
1476
- },
1477
- },
1478
- description: "Array of notes for batch creation",
1479
- },
1480
- },
1481
- },
1482
1243
  execute: async (params) => {
1483
1244
  const batch = params.notes as any[] | undefined;
1484
1245
  const items = batch ?? [params];
@@ -1867,144 +1628,6 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
1867
1628
  // =====================================================================
1868
1629
  {
1869
1630
  name: "update-note",
1870
- requiredVerb: "write",
1871
- 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.
1872
-
1873
- - Three content-modification modes (mutually exclusive):
1874
- - \`content\` — full replace.
1875
- - \`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.
1876
- - \`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.
1877
- - \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
1878
- - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
1879
- - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
1880
- - For batch: pass a \`notes\` array, each with an \`id\` field.
1881
- - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
1882
- - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
1883
- - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`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.
1884
-
1885
- Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
1886
- inputSchema: {
1887
- type: "object",
1888
- properties: {
1889
- id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
1890
- content: { type: "string", description: "New content (full replace). Mutually exclusive with `append`/`prepend` and `content_edit`." },
1891
- 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." },
1892
- 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." },
1893
- content_edit: {
1894
- type: "object",
1895
- properties: {
1896
- old_text: { type: "string", description: "Exact text to find. Must match exactly once in the note's current content." },
1897
- new_text: { type: "string", description: "Replacement text." },
1898
- },
1899
- required: ["old_text", "new_text"],
1900
- description: "Find-and-replace one occurrence. Errors if `old_text` is not found or matches multiple locations. Mutually exclusive with `content` and `append`/`prepend`.",
1901
- },
1902
- path: { type: "string", description: "New path" },
1903
- 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." },
1904
- 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." },
1905
- created_at: { type: "string", description: "New created_at timestamp" },
1906
- 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." },
1907
- 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." },
1908
- 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." },
1909
- state_transition: {
1910
- type: "object",
1911
- properties: {
1912
- field: { type: "string", description: "Metadata field to transition." },
1913
- 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." },
1914
- to: { description: "New value to set when the `from` precondition holds." },
1915
- },
1916
- required: ["field", "from", "to"],
1917
- 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.",
1918
- },
1919
- tags: {
1920
- type: "object",
1921
- properties: {
1922
- add: { type: "array", items: { type: "string" } },
1923
- remove: { type: "array", items: { type: "string" } },
1924
- },
1925
- description: "Tags to add/remove",
1926
- },
1927
- links: {
1928
- type: "object",
1929
- properties: {
1930
- add: {
1931
- type: "array",
1932
- items: {
1933
- type: "object",
1934
- properties: {
1935
- target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
1936
- relationship: { type: "string" },
1937
- },
1938
- required: ["target", "relationship"],
1939
- },
1940
- },
1941
- remove: {
1942
- type: "array",
1943
- items: {
1944
- type: "object",
1945
- properties: {
1946
- target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
1947
- relationship: { type: "string" },
1948
- },
1949
- required: ["target", "relationship"],
1950
- },
1951
- },
1952
- },
1953
- 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.",
1954
- },
1955
- include_content: {
1956
- type: "boolean",
1957
- description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize`, 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.",
1958
- },
1959
- include_links: {
1960
- type: "boolean",
1961
- 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).",
1962
- },
1963
- // Batch
1964
- notes: {
1965
- type: "array",
1966
- items: {
1967
- type: "object",
1968
- properties: {
1969
- id: { type: "string" },
1970
- content: { type: "string" },
1971
- append: { type: "string" },
1972
- prepend: { type: "string" },
1973
- content_edit: {
1974
- type: "object",
1975
- properties: {
1976
- old_text: { type: "string" },
1977
- new_text: { type: "string" },
1978
- },
1979
- required: ["old_text", "new_text"],
1980
- },
1981
- path: { type: "string" },
1982
- extension: { type: "string", description: "Change the note's file extension (vault#328). See top-level docs." },
1983
- metadata: { type: "object" },
1984
- created_at: { type: "string" },
1985
- 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." },
1986
- 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." },
1987
- if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
1988
- state_transition: {
1989
- type: "object",
1990
- properties: {
1991
- field: { type: "string" },
1992
- from: {},
1993
- to: {},
1994
- },
1995
- required: ["field", "from", "to"],
1996
- description: "Per-item compare-and-set state transition (vault#299). See top-level `state_transition` docs.",
1997
- },
1998
- tags: { type: "object" },
1999
- links: { type: "object" },
2000
- 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." },
2001
- },
2002
- required: ["id"],
2003
- },
2004
- description: "Array of note updates for batch",
2005
- },
2006
- },
2007
- },
2008
1631
  execute: async (params) => {
2009
1632
  const batch = params.notes as any[] | undefined;
2010
1633
  // vault#554: top-level `force` / `if_updated_at` apply as per-item
@@ -2537,22 +2160,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2537
2160
  // =====================================================================
2538
2161
  {
2539
2162
  name: "delete-note",
2540
- // `write` — same destructive verb as update-note. Aaron's call
2541
- // 2026-05-27: "delete- in write; right now the only admin gated
2542
- // thing is tokens." Reserving `admin` for "operator-only
2543
- // capabilities" (token mgmt + future config writes). A future
2544
- // finer-grained model might split `vault:write:no-delete` for
2545
- // genuinely append-only callers — gating WITHIN write rather
2546
- // than promoting deletes out of it.
2547
- requiredVerb: "write",
2548
- 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.",
2549
- inputSchema: {
2550
- type: "object",
2551
- properties: {
2552
- id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
2553
- },
2554
- required: ["id"],
2555
- },
2556
2163
  execute: async (params) => {
2557
2164
  const note = requireNote(db, params.id as string);
2558
2165
  await store.deleteNote(note.id);
@@ -2565,15 +2172,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2565
2172
  // =====================================================================
2566
2173
  {
2567
2174
  name: "list-tags",
2568
- requiredVerb: "read",
2569
- 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.`,
2570
- inputSchema: {
2571
- type: "object",
2572
- properties: {
2573
- tag: { type: "string", description: "Get details for a single tag" },
2574
- include_schema: { type: "boolean", description: "Include full tag record (description, fields, relationships, parent_names, timestamps) for each tag (default: false)" },
2575
- },
2576
- },
2577
2175
  execute: (params) => {
2578
2176
  const singleTag = params.tag as string | undefined;
2579
2177
 
@@ -2641,51 +2239,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2641
2239
  // =====================================================================
2642
2240
  {
2643
2241
  name: "update-tag",
2644
- // `admin` (was `write`) — this PR: update-tag defines a tag's SCHEMA
2645
- // (description, indexed-field types, relationship vocabulary,
2646
- // hierarchy parents), which every note carrying the tag inherits.
2647
- // That's structure/taxonomy curation, not content authorship — the
2648
- // same distinction that keeps content out of admin and structure out
2649
- // of write. See the `generateMcpTools` doc comment above for the full
2650
- // re-tier rationale + BREAKING note.
2651
- requiredVerb: "admin",
2652
- 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.",
2653
- inputSchema: {
2654
- type: "object",
2655
- properties: {
2656
- tag: { type: "string", description: "Tag name" },
2657
- description: { type: "string", description: "Human-readable description of what this tag means" },
2658
- fields: {
2659
- type: "object",
2660
- 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).',
2661
- additionalProperties: {
2662
- type: "object",
2663
- properties: {
2664
- 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." },
2665
- description: { type: "string" },
2666
- 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." },
2667
- 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." },
2668
- 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." },
2669
- 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)." },
2670
- required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
2671
- cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
2672
- },
2673
- required: ["type"],
2674
- },
2675
- },
2676
- relationships: {
2677
- type: "object",
2678
- 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" } }.',
2679
- additionalProperties: true,
2680
- },
2681
- parent_names: {
2682
- type: "array",
2683
- items: { type: "string" },
2684
- 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.",
2685
- },
2686
- },
2687
- required: ["tag"],
2688
- },
2689
2242
  execute: async (params) => {
2690
2243
  // Canonical-bare-tag guard (PR #516): normalize the tag NAME up front
2691
2244
  // so the existing-record lookup (and the field/cross-tag merge that
@@ -2784,25 +2337,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2784
2337
  // =====================================================================
2785
2338
  {
2786
2339
  name: "delete-tag",
2787
- // `admin` (was `write` — Aaron's 2026-05-27 call reserved admin for
2788
- // token mgmt + future config writes; deletes were write-tier
2789
- // mutations, see delete-note's rationale). Superseded by this PR:
2790
- // delete-tag removes a tag's identity row + schema and untags it
2791
- // vault-wide — that's structure/taxonomy curation, the same class as
2792
- // update-tag/rename-tag/merge-tags, not content authorship. See the
2793
- // `generateMcpTools` doc comment above for the full re-tier rationale
2794
- // + BREAKING note.
2795
- requiredVerb: "admin",
2796
- 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.",
2797
- inputSchema: {
2798
- type: "object",
2799
- properties: {
2800
- tag: { type: "string", description: "Tag name to delete" },
2801
- cascade: { type: "boolean", description: "Proceed even though another tag's parent_names references this one, stripping the reference. Synonym of detach." },
2802
- detach: { type: "boolean", description: "Same as cascade — proceed and strip the stale parent_names reference from referencing tag(s)." },
2803
- },
2804
- required: ["tag"],
2805
- },
2806
2340
  execute: async (params) => {
2807
2341
  const tag = params.tag as string;
2808
2342
  // Drop the row outright — description/fields/relationships/parents
@@ -2827,25 +2361,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2827
2361
  // =====================================================================
2828
2362
  {
2829
2363
  name: "rename-tag",
2830
- // `admin` (was `write`) — this PR: an atomic cascading rename across
2831
- // note memberships, other tags' parent_names, tokens' allowlists,
2832
- // indexed-field declarer lists, and inline #tag mentions is structural
2833
- // taxonomy surgery, not content authorship. Same tier as
2834
- // update-tag/delete-tag/merge-tags. See the `generateMcpTools` doc
2835
- // comment above for the full re-tier rationale + BREAKING note.
2836
- requiredVerb: "admin",
2837
- description:
2838
- "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.",
2839
- inputSchema: {
2840
- type: "object",
2841
- properties: {
2842
- old_name: { type: "string", description: "The tag to rename. Aliases: from, tag." },
2843
- new_name: { type: "string", description: "The new name. Alias: to." },
2844
- from: { type: "string", description: "Alias for old_name." },
2845
- to: { type: "string", description: "Alias for new_name." },
2846
- tag: { type: "string", description: "Alias for old_name." },
2847
- },
2848
- },
2849
2364
  execute: async (params) => {
2850
2365
  const oldName = (params.old_name ?? params.from ?? params.tag) as string | undefined;
2851
2366
  const newName = (params.new_name ?? params.to) as string | undefined;
@@ -2890,22 +2405,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2890
2405
  // =====================================================================
2891
2406
  {
2892
2407
  name: "merge-tags",
2893
- // `admin` (was `write`) — this PR: merging N source tags into a
2894
- // target (retagging every note, dropping the sources' identity rows)
2895
- // is structural taxonomy surgery, not content authorship. Same tier
2896
- // as update-tag/delete-tag/rename-tag. See the `generateMcpTools` doc
2897
- // comment above for the full re-tier rationale + BREAKING note.
2898
- requiredVerb: "admin",
2899
- description:
2900
- "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.",
2901
- inputSchema: {
2902
- type: "object",
2903
- properties: {
2904
- sources: { type: "array", items: { type: "string" }, description: "Tag names to merge away into target." },
2905
- target: { type: "string", description: "The tag that survives; sources are retagged onto it and dropped." },
2906
- },
2907
- required: ["sources", "target"],
2908
- },
2909
2408
  execute: async (params) => {
2910
2409
  const sources = params.sources;
2911
2410
  const target = params.target;
@@ -2930,17 +2429,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2930
2429
  // =====================================================================
2931
2430
  {
2932
2431
  name: "find-path",
2933
- requiredVerb: "read",
2934
- 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`.",
2935
- inputSchema: {
2936
- type: "object",
2937
- properties: {
2938
- source: { type: "string", description: "Starting note ID, path, or (fallback) H1 title" },
2939
- target: { type: "string", description: "Destination note ID, path, or (fallback) H1 title" },
2940
- max_depth: { type: "number", description: "Max path length (default 5)" },
2941
- },
2942
- required: ["source", "target"],
2943
- },
2944
2432
  execute: (params) => {
2945
2433
  const source = requireNote(db, params.source as string);
2946
2434
  const target = requireNote(db, params.target as string);
@@ -2955,24 +2443,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2955
2443
  // =====================================================================
2956
2444
  {
2957
2445
  name: "vault-info",
2958
- // `read` so vault:read callers can fetch stats. The
2959
- // description-update branch performs an inner ADMIN-check (see
2960
- // overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
2961
- // `admin` or read-only callers lose the stats projection. Was an
2962
- // inner write-check pre-this-PR; writing the vault's own
2963
- // description/config is curation, not content, so it moved to the
2964
- // same admin tier as the other structure-curation tools (update-tag
2965
- // et al) — see the `generateMcpTools` doc comment above.
2966
- requiredVerb: "read",
2967
- 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.",
2968
- inputSchema: {
2969
- type: "object",
2970
- properties: {
2971
- include_stats: { type: "boolean", description: "Include note count, tag count, attachment/link counts, and the monthly note distribution (default: false)" },
2972
- description: { type: "string", description: "If provided, updates the vault description" },
2973
- },
2974
- },
2975
- // execute is overridden in mcp-tools.ts where vault config is available
2976
2446
  execute: () => {
2977
2447
  // This is a placeholder — vault-info needs access to vault config,
2978
2448
  // which is only available in the server layer (mcp-tools.ts).
@@ -2985,20 +2455,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2985
2455
  // =====================================================================
2986
2456
  {
2987
2457
  name: "prune-schema",
2988
- // `admin` — a destructive schema-maintenance op, same tier as
2989
- // manage-token. Operator-only; hidden from read/write sessions.
2990
- requiredVerb: "admin",
2991
- description:
2992
- "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.",
2993
- inputSchema: {
2994
- type: "object",
2995
- properties: {
2996
- apply: {
2997
- type: "boolean",
2998
- description: "Execute the prune. Default false (dry-run — report what would be dropped without changing anything).",
2999
- },
3000
- },
3001
- },
3002
2458
  execute: async (params) => {
3003
2459
  const apply = params.apply === true;
3004
2460
  const plan = await store.pruneIndexedFields({ dryRun: !apply });
@@ -3020,20 +2476,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
3020
2476
  // =====================================================================
3021
2477
  {
3022
2478
  name: "doctor",
3023
- // `read` (was `admin` — the original reasoning: same tier as
3024
- // prune-schema, a diagnostic over the WHOLE vault's taxonomy, not
3025
- // scoped to any one tag's write authority). Superseded by this PR:
3026
- // doctor never mutates and is ALREADY tag-scope-restricted at the MCP
3027
- // layer (see `applyTagScopeWrappers`'s `doctor` wrapper in
3028
- // src/mcp-tools.ts, which re-runs the scan against the caller's
3029
- // allowlist) — it's a read, not a curation op, and read-scoped
3030
- // monitoring/tending jobs need to be able to run it without an admin
3031
- // credential. The REST `GET /api/doctor` endpoint (routing.ts) is
3032
- // re-tiered to `read` too, so both doors agree — no MCP/REST divergence.
3033
- requiredVerb: "read",
3034
- description:
3035
- "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).",
3036
- inputSchema: { type: "object", properties: {} },
3037
2479
  execute: async () => {
3038
2480
  return await store.doctor();
3039
2481
  },
@@ -3052,26 +2494,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
3052
2494
  // =====================================================================
3053
2495
  const ticketSeam = opts?.attachmentTickets;
3054
2496
  if (ticketSeam) {
3055
- tools.push(
2497
+ executorDefs.push(
3056
2498
  {
3057
2499
  name: "request-attachment-upload",
3058
- requiredVerb: "write",
3059
- description:
3060
- "Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does. The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
3061
- inputSchema: {
3062
- type: "object",
3063
- properties: {
3064
- note: { type: "string", description: "Target note ID or path" },
3065
- filename: { type: "string", description: "Original filename — sanitized for a blocked extension (active-content types: .html/.svg/.xml/.js/.css/…) and used to infer the MIME type when `mime_type` is omitted." },
3066
- size_bytes: {
3067
- type: "number",
3068
- description: `Declared upload size in bytes. Must be > 0 and <= ${MAX_TICKET_UPLOAD_BYTES} (100 MiB — the same ceiling REST's own /storage/upload enforces). The spend endpoint rejects (413) any upload that exceeds this declared size.`,
3069
- },
3070
- mime_type: { type: "string", description: "MIME type to store on the attachment row. Inferred from `filename`'s extension when omitted (`application/octet-stream` for an uncurated extension)." },
3071
- transcribe: { type: "boolean", description: "Opt into transcription for an audio attachment — mirrors the REST `POST /notes/:id/attachments` `transcribe` flag." },
3072
- },
3073
- required: ["note", "filename", "size_bytes"],
3074
- },
3075
2500
  execute: async (params) => {
3076
2501
  const noteRef = params.note;
3077
2502
  if (typeof noteRef !== "string" || noteRef.trim() === "") {
@@ -3163,16 +2588,6 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
3163
2588
  },
3164
2589
  {
3165
2590
  name: "request-attachment-download",
3166
- requiredVerb: "read",
3167
- description:
3168
- "Mint a short-lived, single-use download URL for an existing attachment's bytes. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Pass the `attachment_id` from a note's `include_attachments: true` rows (query-notes) or `GET .../attachments`. The ticket's `expires_at` follows the same size-scaled window as upload tickets (10 minutes base, up to 30) and can be spent exactly once.",
3169
- inputSchema: {
3170
- type: "object",
3171
- properties: {
3172
- attachment_id: { type: "string", description: "The attachment's id (from a note's attachment rows)" },
3173
- },
3174
- required: ["attachment_id"],
3175
- },
3176
2591
  execute: async (params) => {
3177
2592
  const attachmentId = params.attachment_id;
3178
2593
  if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
@@ -3250,29 +2665,8 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
3250
2665
  // =====================================================================
3251
2666
  const bytesSeam = opts?.attachmentBytes;
3252
2667
  if (bytesSeam) {
3253
- tools.push({
2668
+ executorDefs.push({
3254
2669
  name: "read-attachment",
3255
- requiredVerb: "read",
3256
- description:
3257
- "Read an attachment's content directly into this conversation (the model lane — bytes DO pass through this tool, unlike request-attachment-upload/download). Behavior depends on mime type: text/* (+ json/ndjson/yaml — csv/markdown are already text/*) returns a byte-windowed `content` slice using the exact query-notes content_offset/content_length/content_next_offset pagination contract (default 65536 bytes / 64 KiB, max 262144 / 256 KiB per call — loop, feeding content_next_offset back as content_offset, for more). image/* returns a real image you can see, capped at 4 MiB raw (over-cap refuses with a pointer to request-attachment-download; content_offset/content_length don't apply to images). audio/video never send bytes — you get back a transcript pointer (transcribe_status, note_id, and a transcript_note when one exists) instead of the audio itself. PDF and other binary formats aren't directly readable here — mint a download ticket with request-attachment-download and process the file with your own runtime.",
3258
- inputSchema: {
3259
- type: "object",
3260
- properties: {
3261
- attachment_id: {
3262
- type: "string",
3263
- description: "The attachment's id (from a note's attachment rows, e.g. include_attachments: true on query-notes)",
3264
- },
3265
- content_offset: {
3266
- type: "number",
3267
- description: "Text attachments only. Byte offset to start reading from (UTF-8 bytes). Defaults to 0.",
3268
- },
3269
- content_length: {
3270
- type: "number",
3271
- description: "Text attachments only. Byte budget for this call. Defaults to 65536 (64 KiB); max 262144 (256 KiB).",
3272
- },
3273
- },
3274
- required: ["attachment_id"],
3275
- },
3276
2670
  execute: async (params) => {
3277
2671
  const attachmentId = params.attachment_id;
3278
2672
  if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
@@ -3349,6 +2743,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
3349
2743
  });
3350
2744
  }
3351
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
+ }
3352
2772
  return tools;
3353
2773
  }
3354
2774