diff --git a/.devin/wiki.json b/.devin/wiki.json index 95975131b..e89a5f4a2 100644 --- a/.devin/wiki.json +++ b/.devin/wiki.json @@ -4,7 +4,7 @@ "content": "Vault Cortex is a remote MCP (Model Context Protocol) server that exposes an Obsidian vault over HTTPS — vault CRUD, hybrid search, task management, structured memory, link-graph queries, and non-markdown file reading (images, PDFs, canvases, data files), plus user-initiated prompts. It runs headless in Docker, working directly with .md files on disk using its own Obsidian-parity parsers and search index. README.md is the authoritative feature description and ARCHITECTURE.md the authoritative design document — derive capability details, defaults, and security specifics from those and the code rather than restating them. The repo also ships a CLI (cli/, published as the vault-cortex npm package) that scaffolds a deployment: npx vault-cortex@latest init." }, { - "content": "The codebase is organized into four internal layers under src/vault-mcp/: (1) obsidian-markdown/ — pure Obsidian/Markdown parsers with no I/O (links, lines/fences, frontmatter, callouts, headings, tasks, memory entries, canvas, plaintext). (2) vault-operations/ — filesystem I/O for vault content (vault-filesystem, vault-patcher, note-mover, memory-store, daily-notes, vault-folder-config, task-mutations, task-format-config, trash-sweeper, asset-operations). (3) search/ — the SQLite FTS5 + sqlite-vec index, embedding pipeline, and chokidar file watcher. (4) mcp-core/ — the MCP protocol surface: streamable-http transport, tool group modules under mcp-core/tools/, and prompt group modules under mcp-core/prompts/. Authentication lives in src/vault-mcp/oauth/ (OAuth 2.1) and src/functions/authorizer.ts (AWS Lambda, path-aware). The :remote image's setup mode — the browser sign-in served before an Obsidian Sync token exists — lives in src/vault-mcp/setup/. Infrastructure-as-code (sst.config.ts) is specific to the AWS reference deployment and should not be documented as the primary deployment path." + "content": "The codebase is organized into four internal layers under src/vault-mcp/: (1) obsidian-markdown/ — pure Obsidian/Markdown parsers with no I/O (links, lines/fences, frontmatter, tags, callouts, headings, tasks, memory entries, canvas, plaintext, PDF). (2) vault-operations/ — filesystem I/O for vault content (vault-filesystem, vault-patcher, note-mover, memory-store, daily-notes, vault-folder-config, task-mutations, task-format-config, trash-sweeper, asset-operations). (3) search/ — the SQLite FTS5 + sqlite-vec index, embedding pipeline, and chokidar file watcher. (4) mcp-core/ — the MCP protocol surface: streamable-http transport, tool group modules under mcp-core/tools/, and prompt group modules under mcp-core/prompts/. Authentication lives in src/vault-mcp/oauth/ (OAuth 2.1) and src/functions/authorizer.ts (AWS Lambda, path-aware). The :remote image's setup mode — the browser sign-in served before an Obsidian Sync token exists — lives in src/vault-mcp/setup/. Infrastructure-as-code (sst.config.ts) is specific to the AWS reference deployment and should not be documented as the primary deployment path." } ], "pages": [ @@ -40,7 +40,7 @@ }, { "title": "Obsidian Markdown Parsing", - "purpose": "The pure parser layer in obsidian-markdown/ — no filesystem I/O: links.ts, lines.ts, frontmatter.ts, callouts.ts, headings.ts, tasks.ts, recurrence.ts, memory-entries.ts, canvas.ts, plaintext.ts, moment-format.ts. This layer encodes the Obsidian-parity behavior (every link form and 'New link format' mode Obsidian recognizes; both Tasks-plugin formats) that distinguishes Vault Cortex from simpler file-based integrations.", + "purpose": "The pure parser layer in obsidian-markdown/ — no filesystem I/O: links.ts, lines.ts, frontmatter.ts, tags.ts, callouts.ts, headings.ts, tasks.ts, recurrence.ts, memory-entries.ts, canvas.ts, plaintext.ts, pdf.ts, pdf-engine.ts, moment-format.ts. This layer encodes the Obsidian-parity behavior (every link form and 'New link format' mode Obsidian recognizes; both Tasks-plugin formats) that distinguishes Vault Cortex from simpler file-based integrations.", "parent": "Architecture" }, { diff --git a/AGENTS.md b/AGENTS.md index 6ec5fdacd..97878445e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ src/ __tests__/docker/ # :remote image boot tests (npm run test:remote-boot): docker-harness, and fixtures/ob, a stub of the obsidian-headless CLI vault-mcp/ server.ts, config.ts # Entry point and env-var loader, the only loose files here - obsidian-markdown/ # Pure parsers and transforms with no I/O (pdf-engine excepted): lines, frontmatter, callouts, headings, links, tasks, recurrence, memory-entries, canvas, plaintext, pdf + pdf-engine, moment-format + obsidian-markdown/ # Pure parsers and transforms with no I/O (pdf-engine excepted): lines, frontmatter, callouts, headings, links, tags, tasks, recurrence, memory-entries, canvas, plaintext, pdf + pdf-engine, moment-format vault-operations/ # Vault read, write, and patch: vault-filesystem (base I/O), vault-patcher, note-mover, memory-store, daily-notes, vault-folder-config, task-mutations, task-format-config, trash-config, trash-sweeper, asset-operations mcp-core/ # MCP protocol surface: mcp-router, tool-registry, tool-availability, tool-definitions, prompt-definitions tools/ # One -tools module per data-layer domain (vault-crud, search, task, memory, daily-note, asset), plus tool-helpers diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 411ea82e2..2928b7e39 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -221,6 +221,10 @@ Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to `vault_search_by_tag` matches a frontmatter tag and every tag nested under it (`project` matches `project/a/b` but not `projects`), or the tag alone with `exact: true`. It returns the most recently modified notes first, up to `limit` (default 20). +Every tag input (`tag` on `vault_search_by_tag` and `vault_list_tasks`, the `tags` filter on `vault_search`) ignores letter case and, unless `exact: true` is set on `vault_search_by_tag`, matches nested tags the same way. Both sides of a comparison are folded with `toLowerCase()`, the rule Obsidian's Tags view groups spellings by. A leading `#` on an input is dropped, and an input that is only `#` is rejected. + +`vault_list_tags` merges spellings that differ only in letter case into one entry. The entry shows the spelling with the most occurrences, where a repeat within one note counts again, and its count is the number of notes carrying any of the spellings. + `vault_recent_notes` sorts by `sort_by` — `"created"` or `"modified"` (default `"modified"`). ### Property discovery + daily notes @@ -232,8 +236,10 @@ Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to | `vault_list_property_values` | `key, folder?, limit?` | readOnlyHint | | `vault_search_by_property` | `key, value, folder?, limit?` | readOnlyHint | -**Promoted properties:** Five frontmatter keys — `title`, `tags`, `type`, `created`, `related` — get dedicated columns in the `notes` table for direct `WHERE`-clause filtering (no `json_extract` needed). In tool responses, these appear as top-level fields; remaining frontmatter keys are returned under `additional_properties` (via `formatNoteMetadata` in `tool-helpers.ts`). All other properties live in a JSON `properties` column — functional for any schema, but without dedicated columns. The property queries match a key as data through `json_each` over that column, never as a JSON path, so a property named `a.b` or `k[0]` is matched like any other. Array values are unpacked via `json_each`, so scalar and list properties both match. +**Promoted properties:** Five frontmatter keys — `title`, `tags` (the first key so spelled in any letter case), `type`, `created`, `related` — get dedicated columns in the `notes` table for direct `WHERE`-clause filtering (no `json_extract` needed). In tool responses, these appear as top-level fields; remaining frontmatter keys are returned under `additional_properties` (via `formatNoteMetadata` in `tool-helpers.ts`). +- **Tags column:** holds the tags Obsidian's Tags view shows for the note, read by `obsidian-markdown/tags.ts` from the first key spelled `tags` in any letter case. A text value is one tag, so `tags: a, b` gives no tags, because a tag name cannot contain `,`. A list keeps its text entries. Each entry loses a leading `#` and a trailing `/`, and only names Obsidian accepts as tags are kept (no spaces, none of the punctuation Obsidian rejects such as `,`, `:` or `+`, and not digits only). +- **Other properties:** every unpromoted key lives in a JSON `properties` column — functional for any schema, but without dedicated columns. The property queries match a key as data through `json_each` over that column, never as a JSON path, so a property named `a.b` or `k[0]` is matched like any other. Array values are unpacked via `json_each`, so scalar and list properties both match. - **Listing values:** `vault_list_property_values`, and the `sample_values` in `vault_list_property_keys`, count each value by the text the tool returns. The number `1` and the text `"1"` both return `"1"`, so they share one count; the text `"1.0"` keeps its own. Counting happens before `limit` and sample selection apply, and each item of a list property counts on its own. - **Matching a value:** `vault_search_by_property` matches text exactly. A value written as a whole, finite number in [YAML 1.2 core schema](https://yaml.org/spec/1.2.2/#1032-tag-resolution) form (`4`, `-0.5`, `1e3`, `0x1F`, `0o17`) also matches stored numbers equal to it as [JavaScript numbers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number#number_encoding). So `"04"` matches the number `4` and the text `"04"`, but not the text `"4"`. Checkbox values match only `"1"` and `"0"`. @@ -576,7 +582,7 @@ The search query and indexing layer is split into seven modules (the embedding p | `search-index.ts` | Factory/closure (`createSearchIndex`), schema, migrations, write operations (`upsertNote`, `removeNote`, `rebuildFromVault`, `upsertFileContent`, `removeFileContent`), embedder + reranker wiring | | `search-queries.ts` | `SearchQueryContext` + 16 query methods — `fullTextSearch`, `memoryRecall`, `searchByTag`, `searchByFolder`, `listTasks`, `recentNotes`, `listAllTags`, `listPropertyKeys`, `listPropertyValues`, `searchByProperty`, `getBacklinks`, `getOutgoingLinks`, `findOrphans`, `brokenLinkCount`, `modifiedOnDate`, `vaultStats` | | `hybrid-search.ts` | `hybridSearch` — runs the note FTS, note vector, file-content FTS, and file-content vector legs, fuses them with RRF, applies cross-encoder reranking; falls back to FTS-only when no embeddings are available | -| `search-helpers.ts` | Pure data transforms — row mappers (`rowToMetadata`, `rowToTaskEntry`, `noteRowToSearchResult`, `fileContentRowToSearchResult`), filters (`noteMatchesSearchFilters`), snippet construction | +| `search-helpers.ts` | Pure data transforms — row mappers (`rowToMetadata`, `rowToTaskEntry`, `noteRowToSearchResult`, `fileContentRowToSearchResult`), filters (`noteMatchesSearchFilters`), tag comparison (`foldTag`, `NESTED_TAG_PREDICATE`), snippet construction | | `fts-query.ts` | FTS5 query sanitization — compound-term handling, reserved-word stripping, phrase extraction | | `rrf.ts` | Reciprocal Rank Fusion scoring (`computeRrfScores`) — rank accumulation, k=60, top-rank bonuses | | `reranker.ts` | Cross-encoder factory (`createReranker`, ms-marco-MiniLM-L-6-v2) + position-aware score blending (`blendScores`, `normalizeScores`) | diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 84527a60b..1fb674a7e 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -2501,3 +2501,37 @@ describe("cannot read daily notes config", () => { }) }, 30_000) }) + +// ── Empty tag inputs ───────────────────────────────────────── + +describe("tag must not be empty", () => { + const EMPTY_TAG_MESSAGE = '[Error]: tag must not be empty after its leading "#"' + + it("vault_search_by_tag rejects a tag that is only a #", async () => { + const result = await callTool({ client, name: "vault_search_by_tag", args: { tag: "#" } }) + expect(result).toEqual({ + content: [{ type: "text", text: EMPTY_TAG_MESSAGE }], + isError: true, + }) + }) + + it("vault_search rejects a tags filter entry that is only a #", async () => { + const result = await callTool({ + client, + name: "vault_search", + args: { query: "note", filters: { tags: ["#"] } }, + }) + expect(result).toEqual({ + content: [{ type: "text", text: EMPTY_TAG_MESSAGE }], + isError: true, + }) + }) + + it("vault_list_tasks rejects a tag that is only a #", async () => { + const result = await callTool({ client, name: "vault_list_tasks", args: { tag: "#" } }) + expect(result).toEqual({ + content: [{ type: "text", text: EMPTY_TAG_MESSAGE }], + isError: true, + }) + }) +}) diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json index 0c1f57144..0d0a8e74c 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json @@ -631,7 +631,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -650,7 +650,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -834,7 +834,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1283,7 +1283,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1302,7 +1302,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1466,7 +1466,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1512,14 +1512,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json index 6f7c45302..fd7aad709 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json @@ -633,7 +633,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -652,7 +652,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -836,7 +836,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1225,7 +1225,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1244,7 +1244,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1408,7 +1408,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1454,14 +1454,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json index 55716d394..dcd5aea60 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json @@ -633,7 +633,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -652,7 +652,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -836,7 +836,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1285,7 +1285,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1304,7 +1304,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1468,7 +1468,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1514,14 +1514,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json index c9f2c21da..4e1ccd4c7 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json @@ -593,7 +593,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -612,7 +612,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -796,7 +796,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1201,7 +1201,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1220,7 +1220,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1384,7 +1384,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1430,14 +1430,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json index 732186f54..5c80dcbb1 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json @@ -592,7 +592,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -611,7 +611,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -795,7 +795,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1200,7 +1200,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1219,7 +1219,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1383,7 +1383,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1429,14 +1429,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json index c1d123826..a82325bc9 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json @@ -534,7 +534,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -553,7 +553,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -737,7 +737,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1146,7 +1146,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1165,7 +1165,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1329,7 +1329,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1375,14 +1375,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json index e4988a82b..f6e22f5ab 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json @@ -494,7 +494,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -513,7 +513,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -697,7 +697,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1062,7 +1062,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1081,7 +1081,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1245,7 +1245,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1291,14 +1291,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json index 8eaf6a8db..02e08dd0f 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json @@ -493,7 +493,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -512,7 +512,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -696,7 +696,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1061,7 +1061,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1080,7 +1080,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1244,7 +1244,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1290,14 +1290,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json index 474d38018..a1f2c2e51 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json @@ -533,7 +533,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -552,7 +552,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -736,7 +736,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1145,7 +1145,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1164,7 +1164,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1328,7 +1328,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1374,14 +1374,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json index 608973ea0..bf47bbb6f 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json @@ -633,7 +633,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -652,7 +652,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -836,7 +836,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -1285,7 +1285,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -1304,7 +1304,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -1468,7 +1468,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1514,14 +1514,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json index ca0865ca4..fe714cdb4 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json @@ -315,7 +315,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -334,7 +334,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -518,7 +518,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -774,7 +774,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -793,7 +793,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -957,7 +957,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1003,14 +1003,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json index b2e09ee15..98246ac3d 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json @@ -275,7 +275,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -294,7 +294,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -478,7 +478,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -690,7 +690,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -709,7 +709,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -873,7 +873,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -919,14 +919,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json index 411cef4df..d892adf63 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json @@ -274,7 +274,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -293,7 +293,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -477,7 +477,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -689,7 +689,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -708,7 +708,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -872,7 +872,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -918,14 +918,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json index dd67d34f9..4d8ab402b 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json @@ -262,7 +262,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -281,7 +281,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -465,7 +465,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -681,7 +681,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -700,7 +700,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -864,7 +864,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -910,14 +910,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json index 47256201b..1d8dacbba 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json @@ -222,7 +222,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -241,7 +241,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -425,7 +425,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -597,7 +597,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -616,7 +616,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -780,7 +780,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -826,14 +826,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json index 37ad40032..1ae2d50fe 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json @@ -221,7 +221,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -240,7 +240,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -424,7 +424,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -596,7 +596,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -615,7 +615,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -779,7 +779,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -825,14 +825,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json index a72de91b0..4abe2fff2 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json @@ -261,7 +261,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -280,7 +280,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -464,7 +464,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -680,7 +680,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -699,7 +699,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -863,7 +863,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -909,14 +909,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json index a2bde8cff..117ef35f3 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json @@ -314,7 +314,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "description": "List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -333,7 +333,7 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { @@ -517,7 +517,7 @@ "minLength": 1 }, "tag": { - "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "description": "Tag on the task line, \"#\" optional; matches nested tags, any letter case", "type": "string", "minLength": 1 }, @@ -773,7 +773,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { @@ -792,7 +792,7 @@ "minLength": 1 }, "tags": { - "description": "Require all listed tags (AND — every tag must be present)", + "description": "Tags, \"#\" optional (e.g. [\"reference\"])", "type": "array", "items": { "type": "string", @@ -956,7 +956,7 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.", "inputSchema": { "type": "object", "properties": { @@ -1002,14 +1002,14 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"#project\", \"project/\", and \"Project\" do not match notes tagged \"project\"; drop the \"#\" and the trailing \"/\", and use the tag's letter case.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", + "description": "Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: \"project\" matches \"project/vault-cortex\" and \"project/a/b\". A nested tag continues with \"/\", so \"project\" never matches \"projects\" or \"my-project\".\n\nExample: vault_search_by_tag({ tag: \"project\" })\nExample: vault_search_by_tag({ tag: \"project\", exact: true, limit: 50 }) — notes tagged \"project\" itself, up to 50\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Use vault_list_tags first to discover available tags.\nPrefer vault_search when you need text-based relevance ranking.\n\nParameters:\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n- \"project/\" matches neither \"project\" nor \"project/a\"; drop the \"/\".\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name.\n\nReturns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; \"\" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "minLength": 1, - "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + "description": "Tag, \"#\" optional (e.g. \"project\", \"session-log\"). Nested tags use \"/\" (e.g. \"project/vault-cortex\")." }, "exact": { "default": false, diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 5c858594a..a5fe043c5 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -2741,8 +2741,7 @@ describe("DISABLED_TOOLS", () => { const TAG_SEARCH_SCOPE_SENTENCE = "When to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query." const TAG_SEARCH_LIST_TAGS_SENTENCE = " Use vault_list_tags first to discover available tags." - const TAG_SEARCH_SEARCH_LINE = - "\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags." + const TAG_SEARCH_SEARCH_LINE = "\nPrefer vault_search when you need text-based relevance ranking." it.each([ { diff --git a/src/vault-mcp/mcp-core/tools/__tests__/tool-helpers.test.ts b/src/vault-mcp/mcp-core/tools/__tests__/tool-helpers.test.ts index da23213dc..42e6dc0cf 100644 --- a/src/vault-mcp/mcp-core/tools/__tests__/tool-helpers.test.ts +++ b/src/vault-mcp/mcp-core/tools/__tests__/tool-helpers.test.ts @@ -6,7 +6,7 @@ import { UnsupportedPropertiesBlockError, } from "../../../obsidian-markdown/frontmatter.js" import type { ToolName } from "../../tool-registry.js" -import { createSafeHandlers, describeTextWindow } from "../tool-helpers.js" +import { createSafeHandlers, describeTextWindow, formatNoteMetadata } from "../tool-helpers.js" /** A handler failure whose cause carries detail the client must never see. */ const failWithCause = async (): Promise => { @@ -407,3 +407,44 @@ describe("describeTextWindow", () => { ).toBe("full.md — lines 1–1 of 1 (end of file)") }) }) + +describe("formatNoteMetadata", () => { + it("keeps only unpromoted keys under additional_properties", () => { + const formatted = formatNoteMetadata({ + path: "a.md", + title: "A", + tags: ["x"], + leading_callout: null, + properties: { + title: "A", + tags: ["x"], + type: "note", + created: "2026-01-01", + related: [], + topic: "tags", + }, + }) + + expect(formatted).toEqual({ + path: "a.md", + title: "A", + tags: ["x"], + additional_properties: { topic: "tags" }, + }) + }) + + it("drops the tags key the index read, in any letter case, and keeps a later one", () => { + const formatted = formatNoteMetadata({ + path: "a.md", + tags: ["x"], + leading_callout: null, + properties: { Tags: ["x"], tags: ["y"] }, + }) + + expect(formatted).toEqual({ + path: "a.md", + tags: ["x"], + additional_properties: { tags: ["y"] }, + }) + }) +}) diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index 9e02832e1..526db335c 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -26,6 +26,7 @@ export const registerSearchTools = ({ Filters — all conditions AND-combine with each other and the text query: - folder: a whole folder, subfolders included — "Projects" covers "Projects/Archive" but not "ProjectsOld/"; ignores ASCII letter case +- tags: every listed frontmatter tag must match, in any letter case; "project" also matches "project/a" - properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: "active" }). Values compare by exact type — pass a number as a number, not "4". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value. - created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter @@ -41,12 +42,14 @@ Errors: - No matches returns { results: [], total: 0 }, not an error - Malformed query syntax is sanitized automatically — the tool never throws a query syntax error - A malformed or calendar-invalid created/modified date filter throws with remediation text ("Use YYYY-MM-DD") +- tag must not be empty after its leading "#" — a tags entry is only "#"; pass the tag name Returns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode ("hybrid" or "fts"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — "hybrid" when vector embeddings contributed, "fts" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is "note" for markdown notes or "file" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. ".canvas", ".pdf", ".txt"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.` : `Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the "narrow by metadata, search by text" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically. Filters — all conditions AND-combine with each other and the text query: - folder: a whole folder, subfolders included — "Projects" covers "Projects/Archive" but not "ProjectsOld/"; ignores ASCII letter case +- tags: every listed frontmatter tag must match, in any letter case; "project" also matches "project/a" - properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: "active" }). Values compare by exact type — pass a number as a number, not "4". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value. - created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter @@ -62,6 +65,7 @@ Errors: - No matches returns { results: [], total: 0 }, not an error - Malformed query syntax is sanitized automatically — the tool never throws a query syntax error - A malformed or calendar-invalid created/modified date filter throws with remediation text ("Use YYYY-MM-DD") +- tag must not be empty after its leading "#" — a tags entry is only "#"; pass the tag name Returns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode ("fts" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is "note" for markdown notes or "file" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. ".canvas", ".pdf", ".txt"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.`, inputSchema: { @@ -72,7 +76,7 @@ Returns: JSON with results array (path, title, snippet, score, tags, folder, typ tags: z .array(z.string().min(1)) .optional() - .describe("Require all listed tags (AND — every tag must be present)"), + .describe('Tags, "#" optional (e.g. ["reference"])'), related: z .array(z.string().min(1)) .optional() @@ -153,12 +157,12 @@ Returns: JSON with results array (path, title, snippet, score, tags, folder, typ TOOL_NAMES.VAULT_SEARCH_BY_TAG, { title: "Search by Tag", - description: `Find notes by frontmatter tag; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: "project" matches "project/vault-cortex" and "project/a/b". A nested tag continues with "/", so "project" never matches "projects" or "my-project". + description: `Find notes by frontmatter tag, ignoring letter case; inline #tags in note bodies are not searched. Unless exact is true, a tag also matches every tag nested under it at any depth: "project" matches "project/vault-cortex" and "project/a/b". A nested tag continues with "/", so "project" never matches "projects" or "my-project". Example: vault_search_by_tag({ tag: "project" }) Example: vault_search_by_tag({ tag: "project", exact: true, limit: 50 }) — notes tagged "project" itself, up to 50 -When to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.${whenToolEnabledText("vault_list_tags", " Use vault_list_tags first to discover available tags.")}${whenToolEnabledText("vault_search", "\nPrefer vault_search when you also need text-based relevance ranking; its tags filter matches the exact tag only, without nested tags.")} +When to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.${whenToolEnabledText("vault_list_tags", " Use vault_list_tags first to discover available tags.")}${whenToolEnabledText("vault_search", "\nPrefer vault_search when you need text-based relevance ranking.")} Parameters: - limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: a result count equal to limit may mean more exist, so raise limit to check. @@ -167,15 +171,16 @@ Behavior: Reads the search index, which picks up a file change within a few seco Errors: - An unknown tag or no matches returns an empty array, not an error. -- "#project", "project/", and "Project" do not match notes tagged "project"; drop the "#" and the trailing "/", and use the tag's letter case. +- "project/" matches neither "project" nor "project/a"; drop the "/". +- tag must not be empty after its leading "#" — tag is "#" alone; pass the tag name. -Returns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; "" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag, not only the matched one), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).`, +Returns: JSON array of notes sorted by most recently modified, then by path ascending. Each note has path, folder (top-level folder; "" at the vault root), bytes (on-disk size), modified (file modification time), and the frontmatter values title (file name without .md when missing or not text), tags (every frontmatter tag Obsidian accepts, in the note's letter case), related ([] when missing), type (null when missing or not text), and created (null when missing or not an ISO date). Timestamps are ISO 8601. When present, a note also has leading_callout (the callout opening its body, as { type, title, body }) and additional_properties (every other frontmatter key).`, inputSchema: { tag: z .string() .min(1) .describe( - 'Tag name without "#" prefix (e.g. "project", "session-log"). Hierarchical tags use "/" separators (e.g. "project/vault-cortex").', + 'Tag, "#" optional (e.g. "project", "session-log"). Nested tags use "/" (e.g. "project/vault-cortex").', ), exact: z .boolean() @@ -212,7 +217,7 @@ Returns: JSON array of notes sorted by most recently modified, then by path asce TOOL_NAMES.VAULT_LIST_TAGS, { title: "List Tags", - description: `List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. "project/vault-cortex") appears as one full entry, not split into segments. Count is unique notes, not occurrences. + description: `List all tags in the vault with note counts, ordered by count descending, then by tag name. Only frontmatter tags Obsidian accepts are counted, not inline #tags. Spellings that differ only in letter case are one tag, shown as the spelling written most often. Each hierarchical tag appears as one full entry, not split into segments. Count is unique notes, not occurrences. Example: vault_list_tags() returns [{ tag: "session-log", count: 42 }, { tag: "project/vault-cortex", count: 8 }, ...] @@ -455,7 +460,7 @@ Returns: JSON array of { value, count } sorted by count descending, then by valu TOOL_NAMES.VAULT_SEARCH_BY_PROPERTY, { title: "Search by Property", - description: `Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: "active") and array properties (tags, related): for arrays, matches if any element equals the value. + description: `Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: "active") and array properties (tags, related). Example: vault_search_by_property({ key: "status", value: "in-progress" }) Example: vault_search_by_property({ key: "type", value: "session-log", folder: "Code Projects" }) @@ -476,7 +481,7 @@ Errors: Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits. - leading_callout appears only when the note has a leading callout. -- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.`, +- additional_properties appears only when frontmatter has keys outside title, type, created, related, and the first key spelled tags in any letter case.`, inputSchema: { key: z.string().min(1).describe('Property key name (e.g. "status", "type", "tags").'), value: z.string().min(1).describe('Value to match (e.g. "active", "4", "1e-7").'), diff --git a/src/vault-mcp/mcp-core/tools/task-tools.ts b/src/vault-mcp/mcp-core/tools/task-tools.ts index 31ad41d6d..3a7a21d35 100644 --- a/src/vault-mcp/mcp-core/tools/task-tools.ts +++ b/src/vault-mcp/mcp-core/tools/task-tools.ts @@ -44,7 +44,8 @@ Parameters: Errors: - A malformed or calendar-invalid date filter throws with remediation text ("Use YYYY-MM-DD") -- path without the ".md" extension is rejected +- "path must end in .md" — add the ".md" extension +- tag must not be empty after its leading "#" — tag is "#" alone; pass the tag name - No matches returns { total: 0, tasks: [] }, not an error Returns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status "done" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.`, @@ -81,7 +82,7 @@ Returns: JSON { total, tasks }. Every task carries path, line, status, status_ch .string() .min(1) .optional() - .describe('Inline task tag, bare name without "#"; parent tags match children'), + .describe('Tag on the task line, "#" optional; matches nested tags, any letter case'), heading: z .union([z.string().min(1), z.array(z.string().min(1)).min(1)]) .optional() diff --git a/src/vault-mcp/mcp-core/tools/tool-helpers.ts b/src/vault-mcp/mcp-core/tools/tool-helpers.ts index 1d9d3f64c..3002fd6a1 100644 --- a/src/vault-mcp/mcp-core/tools/tool-helpers.ts +++ b/src/vault-mcp/mcp-core/tools/tool-helpers.ts @@ -11,6 +11,7 @@ import type { LineWindow } from "../../obsidian-markdown/lines.js" import type { ToolName } from "../tool-registry.js" import type { ToolAvailability } from "../tool-availability.js" import { describeError } from "../../../utils/describe-error.js" +import { isTagsKey } from "../../obsidian-markdown/tags.js" import { OverwriteBlockedError, UnkeepableOpeningBlockError, @@ -37,10 +38,21 @@ export type ToolRegistrationContext = ToolAvailability & config: VaultConfig } -// Frontmatter keys that are already top-level fields on NoteMetadata. -// These are stripped from `properties` before returning to clients -// so the response doesn't contain the same data twice. -const PROMOTED_KEYS = new Set(["title", "tags", "type", "created", "related"]) +// Five frontmatter keys are promoted to top-level fields on NoteMetadata and +// so are stripped from additional_properties, which would otherwise repeat +// them. Four have one fixed spelling. The fifth, `tags`, is spelled per note: +// the index reads the first key spelled `tags` in any letter case (`tags`, +// `Tags`, `TAGS`), as Obsidian does, so that key is the one stripped, and a +// later tags-spelled key, which the index ignores, stays in +// additional_properties. +const FIXED_PROMOTED_KEYS = new Set(["title", "type", "created", "related"]) + +/** The promoted keys of one note: the fixed four plus the tags key its + * frontmatter spells, when it has one. */ +const promotedKeysOfNote = (properties: Record): ReadonlySet => { + const noteTagsKey = Object.keys(properties).find(isTagsKey) + return noteTagsKey ? new Set([...FIXED_PROMOTED_KEYS, noteTagsKey]) : FIXED_PROMOTED_KEYS +} /** Reshapes NoteMetadata for client responses: keeps all top-level fields, * replaces `properties` (full frontmatter, mostly duplicated) with @@ -53,8 +65,9 @@ export const formatNoteMetadata = (meta: { // keep it (the { type, title, body } block) when present. const { properties, leading_callout: leadingCallout, ...fields } = meta + const promotedKeys = promotedKeysOfNote(properties) const additional_properties = Object.fromEntries( - Object.entries(properties).filter(([key]) => !PROMOTED_KEYS.has(key)), + Object.entries(properties).filter(([key]) => !promotedKeys.has(key)), ) return { diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tags.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tags.test.ts new file mode 100644 index 000000000..576805fef --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/__tests__/tags.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest" +import { isObsidianTagName, isTagsKey, parseFrontmatterTags } from "../tags.js" + +describe("isTagsKey", () => { + it.each(["tags", "Tags", "TAGS"])("accepts %s", (key) => { + expect(isTagsKey(key)).toBe(true) + }) + + it.each(["tag", "tags ", "#tags", "related"])("rejects %j", (key) => { + expect(isTagsKey(key)).toBe(false) + }) +}) + +describe("isObsidianTagName", () => { + it.each([ + { label: "letters", name: "project" }, + { label: "letters with digits", name: "y1984" }, + { label: "a nested path", name: "project/vault-cortex" }, + { label: "underscore and hyphen", name: "my_tag-2" }, + { label: "accented letters and an emoji", name: "émoji✨" }, + { label: "a name ending in a slash", name: "foo/" }, + ])("accepts $label", ({ name }) => { + expect(isObsidianTagName(name)).toBe(true) + }) + + it.each([ + { label: "an empty name", name: "" }, + { label: "a space", name: "a b" }, + { label: "a comma", name: "alpha,beta" }, + { label: "a plus sign", name: "c++" }, + { label: "a colon", name: "a:b" }, + { label: "a hash sign", name: "#x" }, + { label: "a square bracket", name: "a]b" }, + { label: "digits only", name: "1984" }, + // Labels stay within 40 characters, past which vitest cuts the title + { label: "a General Punctuation zero-width joiner", name: "a‍b" }, + { label: "a Supplemental Punctuation character", name: "a⸮b" }, + ])("rejects $label", ({ name }) => { + expect(isObsidianTagName(name)).toBe(false) + }) +}) + +describe("parseFrontmatterTags", () => { + it("reads a text value as one tag", () => { + expect(parseFrontmatterTags({ tags: "my-tag" })).toEqual(["my-tag"]) + }) + + it("trims a text value and removes its leading #", () => { + expect(parseFrontmatterTags({ tags: " #my-tag " })).toEqual(["my-tag"]) + }) + + it("reads no tag from a comma-joined text value", () => { + expect(parseFrontmatterTags({ tags: "alpha, beta" })).toEqual([]) + }) + + it("keeps the list entries Obsidian's Tags view keeps, trimmed, without a leading # or a trailing /", () => { + const tags = [ + "Project", + "#Alpha", + " padded ", + 2024, + true, + 3.5, + "a b", + "c++", + "a:b", + "##x", + "y1984", + "émoji✨", + "nested/child/", + "1984", + ] + + expect(parseFrontmatterTags({ tags })).toEqual([ + "Project", + "Alpha", + "padded", + "y1984", + "émoji✨", + "nested/child", + ]) + }) + + it("removes one trailing slash only, so a double slash leaves a slash", () => { + expect(parseFrontmatterTags({ tags: ["foo//"] })).toEqual(["foo/"]) + }) + + it("keeps a repeated tag once per occurrence", () => { + expect(parseFrontmatterTags({ tags: ["project", "Project", "project"] })).toEqual([ + "project", + "Project", + "project", + ]) + }) + + it.each([ + { label: "a mapping", tags: { project: true } }, + { label: "a number", tags: 2024 }, + { label: "a boolean", tags: true }, + { label: "null", tags: null }, + ])("reads no tag from $label", ({ tags }) => { + expect(parseFrontmatterTags({ tags })).toEqual([]) + }) + + it("reads the tags key in any letter case", () => { + expect(parseFrontmatterTags({ Tags: ["x"] })).toEqual(["x"]) + expect(parseFrontmatterTags({ TAGS: "y" })).toEqual(["y"]) + }) + + it("reads only the first key spelled tags, even when its value is empty", () => { + expect(parseFrontmatterTags({ Tags: null, tags: ["x"] })).toEqual([]) + }) + + it("reads no tag when the note has no tags key", () => { + expect(parseFrontmatterTags({ title: "No tags" })).toEqual([]) + }) +}) diff --git a/src/vault-mcp/obsidian-markdown/tags.ts b/src/vault-mcp/obsidian-markdown/tags.ts new file mode 100644 index 000000000..ae1e4d2ef --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/tags.ts @@ -0,0 +1,64 @@ +// ── Frontmatter tags, read the way Obsidian's Tags view reads them ────────── + +/** + * Matches a tag name Obsidian's Tags view accepts: no whitespace, no + * character from the General Punctuation (U+2000–U+206F) or Supplemental + * Punctuation (U+2E00–U+2E7F) blocks, and none of the listed ASCII + * punctuation, so `_`, `-`, `/`, letters, digits, emoji and other Unicode + * pass. A copy of the class Obsidian 1.14.4 tests every tag against (its + * `getTags` and property editor), without the leading `#`. + */ +const TAG_NAME_RE = /^[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~[\]\\\s]+$/ + +/** Matches a name made of ASCII digits only, which Obsidian refuses as a tag + * (`#1984` is not a tag, `#y1984` is), with the same `\d` Obsidian uses. */ +const DIGITS_ONLY_RE = /^\d+$/ + +/** Matches the `tags` property key in any letter case, as Obsidian looks it up. */ +const TAGS_KEY_RE = /^tags$/i + +/** True for a property key Obsidian reads tags from: `tags` in any letter case. */ +export const isTagsKey = (key: string): boolean => TAGS_KEY_RE.test(key) + +/** True for a name that Obsidian's Tags view would list as a tag. */ +export const isObsidianTagName = (name: string): boolean => + TAG_NAME_RE.test(name) && !DIGITS_ONLY_RE.test(name) + +/** The raw value under the first key spelled `tags` in any letter case, the + * only key Obsidian reads: a later `Tags` key is ignored even if the first + * one is empty. */ +const findTagsValue = (frontmatter: Record): unknown => { + const tagsKey = Object.keys(frontmatter).find(isTagsKey) + return tagsKey ? frontmatter[tagsKey] : undefined +} + +/** The text entries of a `tags` value, each trimmed: a text value is one + * entry, a list keeps its text elements, and any other value has none. An + * entry that trims to "" stays here and fails `isObsidianTagName` later. */ +const textEntriesOf = (value: unknown): string[] => { + if (typeof value === "string") return [value.trim()] + if (Array.isArray(value)) { + return value.filter((element) => typeof element === "string").map((element) => element.trim()) + } + return [] +} + +/** The tag name an entry carries: one leading `#` and one trailing `/` + * removed, as Obsidian removes them before testing the name. */ +const tagNameOf = (entry: string): string => { + const withoutHash = entry.startsWith("#") ? entry.slice(1) : entry + return withoutHash.endsWith("/") ? withoutHash.slice(0, -1) : withoutHash +} + +/** + * The tags Obsidian's Tags view shows for a note's properties, as bare names + * in document order. A repeated tag is kept once per occurrence, because + * `listAllTags` picks the spelling it shows by occurrence count. Reproduces + * Obsidian 1.14.4's `parseFrontMatterTags` followed by `getTags`'s name test: + * - the first key spelled `tags` in any letter case is read; + * - a text value is one entry and a list keeps its text elements, trimmed; + * - one leading `#` and one trailing `/` are removed; + * - a name is kept only if `isObsidianTagName` accepts it. + */ +export const parseFrontmatterTags = (frontmatter: Record): string[] => + textEntriesOf(findTagsValue(frontmatter)).map(tagNameOf).filter(isObsidianTagName) diff --git a/src/vault-mcp/search/__tests__/hybrid-search.test.ts b/src/vault-mcp/search/__tests__/hybrid-search.test.ts index 39dd679bb..f72fa3fae 100644 --- a/src/vault-mcp/search/__tests__/hybrid-search.test.ts +++ b/src/vault-mcp/search/__tests__/hybrid-search.test.ts @@ -438,6 +438,40 @@ Content about deployment costs and infrastructure. expect(paths).toEqual(["c.md"]) }) + it("applies the tag filter to vector-only results ignoring letter case and nesting", async () => { + const mockEmbedder = createHybridMockEmbedder() + const hybridIndex = createSearchIndex(":memory:", mockEmbedder) + const taggedNote = "---\ntags: [Work/standups]\n---\n\nStandup notes.\n" + + const noteASourceVersion = hybridIndex.upsertNote( + { filePath: "a.md", rawContent: NOTE_A, fileStat: testStat(1000) }, + logger, + ) + const taggedSourceVersion = hybridIndex.upsertNote( + { filePath: "tagged.md", rawContent: taggedNote, fileStat: testStat(1000) }, + logger, + ) + await hybridIndex.embedNote( + { sourceVersion: noteASourceVersion, notePath: "a.md", rawContent: NOTE_A }, + logger, + ) + await hybridIndex.embedNote( + { sourceVersion: taggedSourceVersion, notePath: "tagged.md", rawContent: taggedNote }, + logger, + ) + + // The query matches no indexed word, so only the vector leg can return + // the note and only the JavaScript mirror of the filter can keep it + const query = "zzqxv" + expect(hybridIndex.fullTextSearch({ query, filters: { tags: ["WORK"] } }, logger)).toEqual([]) + + const { results } = await hybridIndex.hybridSearch( + { query, filters: { tags: ["WORK"] } }, + logger, + ) + expect(results.map((result) => result.path)).toEqual(["tagged.md"]) + }) + it("applies type filter to vector-only results", async () => { const mockEmbedder = createHybridMockEmbedder() const hybridIndex = createSearchIndex(":memory:", mockEmbedder) diff --git a/src/vault-mcp/search/__tests__/search-helpers.test.ts b/src/vault-mcp/search/__tests__/search-helpers.test.ts index a1021ee53..e72f44d35 100644 --- a/src/vault-mcp/search/__tests__/search-helpers.test.ts +++ b/src/vault-mcp/search/__tests__/search-helpers.test.ts @@ -16,6 +16,10 @@ import { pathIsInFolder, dayToEpochMsRange, parseNoteForIndex, + foldTag, + normalizeTagQuery, + tagMatchesQuery, + nestedTagLikePattern, } from "../search-helpers.js" import type { NoteRow, TaskRow } from "../search-index.js" import { parseNote, UnsupportedPropertiesBlockError } from "../../obsidian-markdown/frontmatter.js" @@ -497,6 +501,88 @@ describe("fileContentRowToSearchResult", () => { }) }) +// ── Tag comparison ─────────────────────────────────────────────────── + +describe("foldTag", () => { + it("lowercases ASCII and accented letters", () => { + expect(foldTag("Project")).toBe("project") + expect(foldTag("Ärger")).toBe("ärger") + }) + + it("folds a capital sharp s to the small one, so the pair compares equal", () => { + expect(foldTag("ẞ")).toBe("ß") + expect(foldTag("ß")).toBe("ß") + }) + + it("keeps sharp s apart from double s, which Obsidian lists apart", () => { + expect(foldTag("Straße")).toBe("straße") + expect(foldTag("STRASSE")).toBe("strasse") + }) +}) + +describe("normalizeTagQuery", () => { + it("removes one leading # and folds", () => { + expect(normalizeTagQuery("#Project")).toBe("project") + expect(normalizeTagQuery("##x")).toBe("#x") + }) + + it("keeps a trailing slash, which only a stored tag ending in a slash equals", () => { + expect(normalizeTagQuery("foo/")).toBe("foo/") + }) + + it("throws when nothing is left after the leading #", () => { + expect(() => normalizeTagQuery("#")).toThrow( + new Error('tag must not be empty after its leading "#"'), + ) + }) +}) + +describe("tagMatchesQuery", () => { + it.each([ + { + label: "the tag itself in another letter case", + storedTag: "Project", + foldedQuery: "project", + }, + { label: "a nested tag", storedTag: "project/alpha", foldedQuery: "project" }, + { + label: "a nested tag in another letter case", + storedTag: "PROJECT/Alpha", + foldedQuery: "project", + }, + ])("matches $label", ({ storedTag, foldedQuery }) => { + expect(tagMatchesQuery({ storedTag, foldedQuery })).toBe(true) + }) + + it.each([ + { + label: "a longer tag without a slash boundary", + storedTag: "projects", + foldedQuery: "project", + }, + { + label: "a tag that only ends with the query", + storedTag: "my-project", + foldedQuery: "project", + }, + { label: "the parent of the query", storedTag: "project", foldedQuery: "project/alpha" }, + ])("rejects $label", ({ storedTag, foldedQuery }) => { + expect(tagMatchesQuery({ storedTag, foldedQuery })).toBe(false) + }) +}) + +describe("nestedTagLikePattern", () => { + it("folds, removes the leading #, escapes LIKE wildcards, and appends /%", () => { + expect(nestedTagLikePattern("#A_b%")).toBe("a\\_b\\%/%") + }) + + it("throws for an input that is only a #", () => { + expect(() => nestedTagLikePattern("#")).toThrow( + new Error('tag must not be empty after its leading "#"'), + ) + }) +}) + // ── noteMatchesSearchFilters ───────────────────────────────────────── describe("noteMatchesSearchFilters", () => { @@ -528,6 +614,26 @@ describe("noteMatchesSearchFilters", () => { expect(noteMatchesSearchFilters(baseRow, { tags: ["project"] })).toBe(true) expect(noteMatchesSearchFilters(baseRow, { tags: ["project", "project/alpha"] })).toBe(true) expect(noteMatchesSearchFilters(baseRow, { tags: ["missing"] })).toBe(false) + expect(noteMatchesSearchFilters(baseRow, { tags: ["project", "missing"] })).toBe(false) + }) + + it("matches a tag filter ignoring letter case and a leading #", () => { + expect(noteMatchesSearchFilters(baseRow, { tags: ["PROJECT"] })).toBe(true) + expect(noteMatchesSearchFilters(baseRow, { tags: ["#Project/Alpha"] })).toBe(true) + }) + + it("matches a tag filter by a parent tag but not by a longer or child-only name", () => { + const childOnlyRow = { ...baseRow, tags: JSON.stringify(["project/alpha"]) } + + expect(noteMatchesSearchFilters(childOnlyRow, { tags: ["project"] })).toBe(true) + expect(noteMatchesSearchFilters(childOnlyRow, { tags: ["alpha"] })).toBe(false) + expect(noteMatchesSearchFilters(baseRow, { tags: ["projects"] })).toBe(false) + }) + + it("rejects a tag filter that is only a #", () => { + expect(() => noteMatchesSearchFilters(baseRow, { tags: ["#"] })).toThrow( + new Error('tag must not be empty after its leading "#"'), + ) }) it("filters by type", () => { diff --git a/src/vault-mcp/search/__tests__/search-index.test.ts b/src/vault-mcp/search/__tests__/search-index.test.ts index f097d103f..78a2555d5 100644 --- a/src/vault-mcp/search/__tests__/search-index.test.ts +++ b/src/vault-mcp/search/__tests__/search-index.test.ts @@ -1253,6 +1253,25 @@ describe("upsertNote", () => { expect(tags).toEqual([{ tag: "single-tag", count: 1 }]) }) + it.each([ + // Labels stay within 40 characters, past which vitest cuts the title + { label: 'a quoted "#Alpha" as Alpha', frontmatter: 'tags: ["#Alpha"]', stored: ["Alpha"] }, + { label: "a comma-joined text value as no tag", frontmatter: "tags: alpha, beta", stored: [] }, + { label: "a bare number as no tag", frontmatter: "tags: 2024", stored: [] }, + { label: "the tags under a Tags key", frontmatter: "Tags: [x]", stored: ["x"] }, + ])("stores $label", ({ frontmatter, stored }) => { + index.upsertNote( + { + filePath: "tagged.md", + rawContent: `---\n${frontmatter}\n---\nbody\n`, + fileStat: testStat(1000), + }, + logger, + ) + + expect(index.recentNotes({}, logger).map((note) => note.tags)).toEqual([stored]) + }) + it("exposes the status registry it was built with", () => { const statusRegistry: ReadonlyMap = new Map([ [" ", "todo"], @@ -1655,6 +1674,42 @@ describe("fullTextSearch", () => { expect(results[0]?.path).toBe("Projects/notes.md") }) + it("matches a tags filter by a parent tag in any letter case, not a longer tag", () => { + index.upsertNote( + { + filePath: "Projects/child.md", + rawContent: "---\ntags: [Project/child]\n---\n\nMeeting notes about the child\n", + fileStat: testStat(4000), + }, + logger, + ) + index.upsertNote( + { + filePath: "Projects/decoy.md", + rawContent: "---\ntags: [projects]\n---\n\nMeeting notes about the decoy\n", + fileStat: testStat(5000), + }, + logger, + ) + + const results = index.fullTextSearch( + { query: "notes", filters: { tags: ["#PROJECT"] } }, + logger, + ) + expect(results.map((result) => result.path).toSorted()).toEqual([ + "Projects/child.md", + "Projects/notes.md", + ]) + }) + + it("rejects a tags filter entry that is only a #", () => { + const searchWithEmptyTag = () => { + return index.fullTextSearch({ query: "notes", filters: { tags: ["#"] } }, logger) + } + + expect(searchWithEmptyTag).toThrow(new Error('tag must not be empty after its leading "#"')) + }) + it("respects type filter", () => { const results = index.fullTextSearch({ query: "notes", filters: { type: "project" } }, logger) expect(results).toHaveLength(1) @@ -1971,10 +2026,7 @@ describe("searchByTag", () => { expect(results.map((result) => result.path)).toEqual(["b.md", "a.md"]) }) - it.each([ - { label: 'a leading "#"', tag: "#project" }, - { label: 'a trailing "/"', tag: "project/" }, - ])("matches no note tagged project when the tag has $label", ({ tag }) => { + it('matches notes tagged project when the tag has a leading "#", but not a trailing "/"', () => { index.upsertNote( { filePath: "d.md", @@ -1983,12 +2035,112 @@ describe("searchByTag", () => { }, logger, ) - // The bare tag finds d.md, so the empty result below comes from the - // "#" or "/" alone, not from a fixture without a matching note. + // The bare tag finds d.md, so the empty "/" result below comes from the + // "/" alone, not from a fixture without a matching note. const bareTagResults = index.searchByTag({ tag: "project" }, logger) expect(bareTagResults.map((result) => result.path)).toEqual(["d.md", "b.md", "a.md"]) - expect(index.searchByTag({ tag }, logger)).toEqual([]) + const hashResults = index.searchByTag({ tag: "#project" }, logger) + expect(hashResults.map((result) => result.path)).toEqual(["d.md", "b.md", "a.md"]) + expect(index.searchByTag({ tag: "project/" }, logger)).toEqual([]) + }) + + it("treats a stored tag that ends in a slash as itself and as nested under its parent", () => { + index.upsertNote( + { + filePath: "d.md", + rawContent: "---\ntags: [foo//]\n---\nbody\n", + fileStat: testStat(4000), + }, + logger, + ) + + // `foo//` is stored as `foo/`: the query `foo/` is the tag itself, the + // query `foo` is its parent, and exact mode with `foo` is neither + expect(index.searchByTag({ tag: "foo/" }, logger).map((result) => result.path)).toEqual([ + "d.md", + ]) + expect(index.searchByTag({ tag: "foo" }, logger).map((result) => result.path)).toEqual(["d.md"]) + expect(index.searchByTag({ tag: "foo", exact: true }, logger)).toEqual([]) + }) + + it("ignores letter case in both modes, with Projects and my-project as decoys", () => { + index.upsertNote( + { + filePath: "d.md", + rawContent: "---\ntags: [Project]\n---\nbody\n", + fileStat: testStat(4000), + }, + logger, + ) + index.upsertNote( + { + filePath: "e.md", + rawContent: "---\ntags: [project]\n---\nbody\n", + fileStat: testStat(5000), + }, + logger, + ) + // The decoys sit in a note of their own, so a match on either one would + // add f.md to the results + index.upsertNote( + { + filePath: "f.md", + rawContent: "---\ntags: [Projects, my-project]\n---\nbody\n", + fileStat: testStat(6000), + }, + logger, + ) + + expect(index.searchByTag({ tag: "PROJECT" }, logger).map((result) => result.path)).toEqual([ + "e.md", + "d.md", + "b.md", + "a.md", + ]) + expect( + index.searchByTag({ tag: "PROJECT", exact: true }, logger).map((result) => result.path), + ).toEqual(["e.md", "d.md"]) + }) + + it("folds the query the way it folds stored tags, so a capital sharp s finds a small one", () => { + index.upsertNote( + { + filePath: "d.md", + rawContent: "---\ntags: [ß, Straße]\n---\nbody\n", + fileStat: testStat(4000), + }, + logger, + ) + + expect(index.searchByTag({ tag: "ẞ" }, logger).map((result) => result.path)).toEqual(["d.md"]) + expect(index.searchByTag({ tag: "STRASSE" }, logger)).toEqual([]) + }) + + it("folds a stored non-ASCII tag in prefix mode, where LIKE alone ignores only ASCII case", () => { + index.upsertNote( + { + filePath: "d.md", + rawContent: "---\ntags: [Ärger/Alt]\n---\nbody\n", + fileStat: testStat(4000), + }, + logger, + ) + + expect(index.searchByTag({ tag: "ärger" }, logger).map((result) => result.path)).toEqual([ + "d.md", + ]) + }) + + it('removes a leading "#" in exact mode too', () => { + const results = index.searchByTag({ tag: "#Project/Vault-MCP", exact: true }, logger) + expect(results.map((result) => result.path)).toEqual(["a.md"]) + }) + + it("rejects a tag that is only a #", () => { + expect(() => index.searchByTag({ tag: "#" }, logger)).toThrow( + new Error('tag must not be empty after its leading "#"'), + ) }) it("exact match returns the tag itself, not tags nested under it", () => { @@ -2186,6 +2338,61 @@ describe("listAllTags", () => { }) }) + it("merges spellings that differ in letter case under the most-used spelling, counting notes", () => { + index.upsertNote( + { + filePath: "b.md", + rawContent: "---\ntags: [work]\n---\nbody\n", + fileStat: testStat(3000), + }, + logger, + ) + index.upsertNote( + { + filePath: "c.md", + rawContent: "---\ntags: [Work, Work, Work]\n---\nbody\n", + fileStat: testStat(4000), + }, + logger, + ) + index.upsertNote( + { + filePath: "d.md", + rawContent: "---\ntags: [WORK]\n---\nbody\n", + fileStat: testStat(5000), + }, + logger, + ) + + // `Work` wins with three occurrences in one note. Ranking by notes would + // pick `work` (two notes), and the binary tie-break alone would pick `WORK` + expect(index.listAllTags({}, logger)).toEqual([ + { tag: "Work", count: 4 }, + { tag: "principles", count: 2 }, + { tag: "self", count: 1 }, + ]) + }) + + it("breaks a spelling tie by binary order and counts a note with both spellings once", () => { + index.upsertNote( + { + filePath: "b.md", + rawContent: "---\ntags: [tie, Tie]\n---\nbody\n", + fileStat: testStat(3000), + }, + logger, + ) + + // One occurrence each: `Tie` sorts before `tie` in binary order, though + // `tie` comes first in the note, and the single note carrying both counts once + expect(index.listAllTags({}, logger)).toEqual([ + { tag: "principles", count: 2 }, + { tag: "Tie", count: 1 }, + { tag: "self", count: 1 }, + { tag: "work", count: 1 }, + ]) + }) + it("adds no tag for a mapping-valued tags property", () => { const tagsBefore = index.listAllTags({}, logger) index.upsertNote( @@ -5962,6 +6169,26 @@ It has multiple sentences to verify chunking works correctly. ) }) + it("prefixes the tags the index stores: a Tags key read, # removed, invalid names left out", async () => { + const mockEmbedder = createMockEmbedder() + const enrichedIndex = createSearchIndex(":memory:", mockEmbedder, undefined, { + ranking: { enrichChunkMetadata: true }, + }) + const rawContent = + '---\ntitle: Tagged Note\nTags: ["#Search", "a b", "1984"]\n---\n\nBody content.\n' + + const sourceVersion = seedEmbeddingSource(enrichedIndex, { + notePath: "tagged.md", + rawContent, + }) + await enrichedIndex.embedNote({ sourceVersion, notePath: "tagged.md", rawContent }, logger) + + expect(mockEmbedder.embedText).toHaveBeenCalledTimes(1) + expect(mockEmbedder.embedText).toHaveBeenCalledWith( + "Tagged Note\nTags: Search.\n\n\nBody content.", + ) + }) + it("embeds unprefixed chunk text under enrichment when the note has no type or tags", async () => { const mockEmbedder = createMockEmbedder() const enrichedIndex = createSearchIndex(":memory:", mockEmbedder, undefined, { diff --git a/src/vault-mcp/search/__tests__/task-queries.test.ts b/src/vault-mcp/search/__tests__/task-queries.test.ts index d507295ee..91997ff3f 100644 --- a/src/vault-mcp/search/__tests__/task-queries.test.ts +++ b/src/vault-mcp/search/__tests__/task-queries.test.ts @@ -595,6 +595,35 @@ describe("listTasks scope filters", () => { ]) }) + it("matches the tag filter ignoring letter case and a leading #, not a longer tag", () => { + const index = indexWithBoardAndPlain() + index.upsertNote( + { + filePath: "decoys.md", + rawContent: ["- [ ] Longer tag task #errands", "- [ ] Suffix tag task #my-errand"].join( + "\n", + ), + fileStat: testStat(3000), + }, + logger, + ) + + expect( + index.listTasks({ tag: "ERRAND" }, logger).tasks.map((entry) => entry.description), + ).toEqual(["Standalone task #errand"]) + expect( + index.listTasks({ tag: "#errand" }, logger).tasks.map((entry) => entry.description), + ).toEqual(["Standalone task #errand"]) + }) + + it("rejects a tag filter that is only a #", () => { + const index = indexWithBoardAndPlain() + + expect(() => index.listTasks({ tag: "#" }, logger)).toThrow( + new Error('tag must not be empty after its leading "#"'), + ) + }) + it("treats LIKE wildcards in the tag as literal characters when matching children", () => { const index = createTestIndex() index.upsertNote( diff --git a/src/vault-mcp/search/search-helpers.ts b/src/vault-mcp/search/search-helpers.ts index 095bbccb2..5e5a00230 100644 --- a/src/vault-mcp/search/search-helpers.ts +++ b/src/vault-mcp/search/search-helpers.ts @@ -32,12 +32,57 @@ export const coerceToArray = (value: unknown): string[] => { if (Array.isArray(value)) return value.filter((element) => element != null && typeof element !== "object").map(String) - // A mapping value (tags: { project: true }) is dropped, as the array branch + // A mapping value (related: { note: true }) is dropped, as the array branch // above drops mappings in a list; String() would index it as "[object Object]". if (typeof value === "object") return [] return value ? [String(value)] : [] } +// ── Tag comparison ───────────────────────────────────────────── + +/** Folds a tag for comparison with `toLowerCase()`, the rule Obsidian's Tags + * view groups spellings by. Shared by the `fold_tag` SQL function (registered + * in search-index.ts) and the JavaScript mirror of the SQL tag filters, so + * both compare by one rule. */ +export const foldTag = (tag: string): string => tag.toLowerCase() + +/** + * Folds a tag input the way the index compares it: one leading `#` removed, + * then `foldTag`. Throws when nothing is left, because an empty name is not a + * tag and a clear error beats a silently empty result. Tool schemas reject an + * empty string first (`.min(1)`), so the input that reaches the throw is "#". + */ +export const normalizeTagQuery = (input: string): string => { + const tagName = input.startsWith("#") ? input.slice(1) : input + + if (tagName === "") throw new Error('tag must not be empty after its leading "#"') + return foldTag(tagName) +} + +/** True when a stored tag is the folded query or nested under it. The + * JavaScript side of NESTED_TAG_PREDICATE. */ +export const tagMatchesQuery = (params: { storedTag: string; foldedQuery: string }): boolean => { + const foldedStoredTag = foldTag(params.storedTag) + return ( + foldedStoredTag === params.foldedQuery || foldedStoredTag.startsWith(`${params.foldedQuery}/`) + ) +} + +/** + * SQL predicate over a `json_each` `value` that matches the query tag itself + * or any tag nested under it, folding each stored value once; bound to + * `nestedTagLikePattern(input)`. Appending `/` to the stored value turns + * "equal or nested" into one LIKE (`||` binds tighter than LIKE), so + * `project/%` matches `project` and `project/x` but not `projects`. + */ +export const NESTED_TAG_PREDICATE = "fold_tag(value) || '/' LIKE ? ESCAPE '\\'" + +/** The pattern NESTED_TAG_PREDICATE binds for a tag input: normalized, its + * LIKE wildcards escaped, then `/%`. Throws like normalizeTagQuery. */ +export const nestedTagLikePattern = (input: string): string => { + return `${escapeLikeWildcards(normalizeTagQuery(input))}/%` +} + // ── Note parsing for the index ───────────────────────────────── /** @@ -309,14 +354,19 @@ const propertyValueMatches = (params: { } /** Mirrors fullTextSearch's SQL filters for vector-only results. Date filter - * values are pre-validated by fullTextSearch, which hybridSearch runs first. */ + * values and tag inputs are pre-validated by fullTextSearch, which + * hybridSearch runs first, so their throws never fire here. */ export const noteMatchesSearchFilters = (note: NoteRow, filters: SearchFilters): boolean => { if (filters.folder && !pathIsInFolder({ path: note.path, folder: filters.folder })) return false if (filters.tags) { const noteTags = parseStringArray(note.tags) + const foldedQueries = filters.tags.map(normalizeTagQuery) + const everyQueryMatches = foldedQueries.every((foldedQuery) => { + return noteTags.some((storedTag) => tagMatchesQuery({ storedTag, foldedQuery })) + }) - if (!filters.tags.every((tag) => noteTags.includes(tag))) return false + if (!everyQueryMatches) return false } if (filters.type && note.type !== filters.type) return false diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 467f38ed2..f4b65145b 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -13,6 +13,7 @@ import { links } from "../obsidian-markdown/links.js" import { splitIntoLines } from "../obsidian-markdown/lines.js" import { parseHeadings } from "../obsidian-markdown/headings.js" import { parseMemoryEntries, type MemoryEntry } from "../obsidian-markdown/memory-entries.js" +import { parseFrontmatterTags } from "../obsidian-markdown/tags.js" import { DEFAULT_STATUS_REGISTRY, tasks } from "../obsidian-markdown/tasks.js" import type { StatusClassification, TaskPriority, TaskStatus } from "../obsidian-markdown/tasks.js" import { contentHash, type Embedder } from "./embedder.js" @@ -30,6 +31,7 @@ import { coerceToArray, buildFtsMetadataText, escapeLikeWildcards, + foldTag, parseNoteForIndex, } from "./search-helpers.js" import * as queries from "./search-queries.js" @@ -365,6 +367,12 @@ export const createSearchIndex = ( return String(value) }) + // Tag predicates compare folded values; the same fold runs in the + // JavaScript mirror of the search filters (foldTag) + db.function("fold_tag", { deterministic: true }, (value: unknown): string => { + return foldTag(String(value)) + }) + db.pragma("journal_mode = WAL") db.pragma("synchronous = NORMAL") // better-sqlite3 already defaults sqlite3_busy_timeout to 5000 ms at open; @@ -1455,8 +1463,11 @@ export const createSearchIndex = ( unreadableBlockError, } = parseNoteForIndex(rawContent) - const tags = coerceToArray(frontmatter.tags) + // Only tags follow Obsidian's Tags view rules (any letter case for the + // key, the tag-name test); related keeps every scalar value + const tags = parseFrontmatterTags(frontmatter) const related = coerceToArray(frontmatter.related) + const bodyLines = splitIntoLines(noteBody) // Store the leading callout (a top-of-file `> [!type]` block — info, @@ -1681,7 +1692,7 @@ export const createSearchIndex = ( const metadataPrefix = options?.ranking?.enrichChunkMetadata ? buildChunkMetadataPrefix({ type: isString(frontmatter.type) ? frontmatter.type : null, - tags: coerceToArray(frontmatter.tags), + tags: parseFrontmatterTags(frontmatter), }) : null const chunks = chunkContent({ diff --git a/src/vault-mcp/search/search-queries.ts b/src/vault-mcp/search/search-queries.ts index d9d2e6bbe..8bb1bc6b9 100644 --- a/src/vault-mcp/search/search-queries.ts +++ b/src/vault-mcp/search/search-queries.ts @@ -18,6 +18,9 @@ import { folderLikePattern, stripTrailingSlashes, dayToEpochMsRange, + normalizeTagQuery, + nestedTagLikePattern, + NESTED_TAG_PREDICATE, } from "./search-helpers.js" import type { FileContentFtsRow } from "./search-helpers.js" import type { @@ -173,9 +176,12 @@ export const fullTextSearch = ( } if (params.filters?.tags) { + // Each listed tag matches the tag itself or any tag nested under it, as + // Obsidian's tag: search operator does; the JavaScript mirror is + // noteMatchesSearchFilters for (const tag of params.filters.tags) { - conditions.push("EXISTS (SELECT 1 FROM json_each(n.tags) WHERE value = ?)") - queryParams.push(tag) + conditions.push(`EXISTS (SELECT 1 FROM json_each(n.tags) WHERE ${NESTED_TAG_PREDICATE})`) + queryParams.push(nestedTagLikePattern(tag)) } } @@ -762,7 +768,8 @@ export const memoryRecall = async ( // ── Discovery queries ────────────────────────────────────────── -/** Finds notes with a specific tag. Supports hierarchical prefix matching. */ +/** Finds notes with a tag, ignoring letter case. Unless `exact` is set, a tag + * nested under it matches too (`project` matches `project/a`, not `projects`). */ export const searchByTag = ( context: SearchQueryContext, params: { @@ -774,23 +781,19 @@ export const searchByTag = ( ): NoteMetadata[] => { const limit = Math.max(0, Math.floor(params.limit ?? 20)) - const condition = params.exact - ? "EXISTS (SELECT 1 FROM json_each(n.tags) WHERE value = ?)" - : "EXISTS (SELECT 1 FROM json_each(n.tags) WHERE value = ? OR value LIKE ? || '/%' ESCAPE '\\')" - - const queryParams: unknown[] = params.exact - ? [params.tag, limit] - : [params.tag, escapeLikeWildcards(params.tag), limit] + const tagMatch = params.exact + ? { predicate: "fold_tag(value) = ?", boundValue: normalizeTagQuery(params.tag) } + : { predicate: NESTED_TAG_PREDICATE, boundValue: nestedTagLikePattern(params.tag) } const sql = ` SELECT path, title, tags, related, folder, type, created, mtime, properties, leading_callout, bytes FROM notes n - WHERE ${condition} + WHERE EXISTS (SELECT 1 FROM json_each(n.tags) WHERE ${tagMatch.predicate}) ORDER BY mtime DESC, path LIMIT ? ` - const rows = context.db.prepare(sql).all(...queryParams) + const rows = context.db.prepare(sql).all(tagMatch.boundValue, limit) const results = rows.map(rowToMetadata) logger.info("search by tag", { tag: params.tag, @@ -1034,12 +1037,11 @@ export const listTasks = ( } if (params.tag !== undefined) { - // Same nested-tag semantics as searchByTag's prefix mode: "project" - // matches both #project and #project/vault-cortex. - conditions.push( - "EXISTS (SELECT 1 FROM json_each(t.tags) WHERE value = ? OR value LIKE ? || '/%' ESCAPE '\\')", - ) - queryParams.push(params.tag, escapeLikeWildcards(params.tag)) + // Same nested-tag semantics as searchByTag without `exact`: "project" + // matches the stored task tags project and project/vault-cortex (kept + // without "#"), in any letter case. + conditions.push(`EXISTS (SELECT 1 FROM json_each(t.tags) WHERE ${NESTED_TAG_PREDICATE})`) + queryParams.push(nestedTagLikePattern(params.tag)) } if (params.heading !== undefined) { @@ -1123,16 +1125,39 @@ export const listTasks = ( return { total, tasks: taskEntries } } -/** Returns all tags in the vault with their note counts. */ +/** Returns all tags in the vault with their note counts, spellings that differ + * only in letter case merged under the most-used spelling. */ export const listAllTags = ( context: SearchQueryContext, _params: Record, logger: Logger, ): TagCount[] => { + // Spellings that fold to the same lowercase tag form one group: + // - the group's count is the number of distinct notes carrying any of its + // spellings, so a note tagged both `Project` and `project` counts once; + // - the spelling shown is the one with the most occurrences, a repeat within + // one note counting again, which is Obsidian's Tags view rule; + // - a tie goes to the spelling first in SQLite's default BINARY collation + // (byte order). const sql = ` - SELECT value as tag, COUNT(DISTINCT notes.path) as count - FROM notes, json_each(notes.tags) - GROUP BY value + WITH spelling AS ( + SELECT fold_tag(value) AS folded, value AS tag, COUNT(*) AS occurrences + FROM notes, json_each(notes.tags) + GROUP BY folded, tag + ), + folded_count AS ( + SELECT fold_tag(value) AS folded, COUNT(DISTINCT notes.path) AS count + FROM notes, json_each(notes.tags) + GROUP BY folded + ), + ranked AS ( + SELECT spelling.folded, spelling.tag, + ROW_NUMBER() OVER (PARTITION BY spelling.folded ORDER BY spelling.occurrences DESC, spelling.tag) AS rank + FROM spelling + ) + SELECT ranked.tag AS tag, folded_count.count AS count + FROM ranked JOIN folded_count ON folded_count.folded = ranked.folded + WHERE ranked.rank = 1 ORDER BY count DESC, tag ` const results = context.db.prepare(sql).all()