neuro-vault-mcp 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  - 🧠 **Semantic search over your existing vault** — reuses [Smart Connections](https://github.com/brianpetro/obsidian-smart-connections) embeddings already in your vault. No re-indexing, no API keys, no extra infrastructure.
16
16
  - 🎯 **Mode-aware retrieval** — `quick` for direct lookups, `deep` for exploratory questions with block-level results and semantic expansion of related notes.
17
17
  - ✍️ **Direct vault operations** — read, create, append, and prepend notes; manage frontmatter properties and inspect tags (including daily notes) straight from your AI assistant via the [Obsidian CLI](https://github.com/AlexMost/obsidian-cli).
18
+ - 🔎 **Structured queries** — one MongoDB-style call (`query_notes`) replaces N+1 chains like "list tags → read each note's property". Ask "active projects with #ai" or "todo tasks created this week" and get a single ranked response.
18
19
  - ⚡ **Zero infrastructure** — local stdio MCP server, in-memory index, no database, no background processes, no watchers.
19
20
  - 🔌 **Drop-in for any MCP client** — Claude Code, Cursor, Windsurf — configuration is a single JSON block.
20
21
 
@@ -70,7 +71,7 @@ User guide lives in [`docs/guide/`](./docs/guide/README.md):
70
71
 
71
72
  - [Installation](./docs/guide/installation.md)
72
73
  - [Semantic Search](./docs/guide/semantic-search.md) — `search_notes`, `get_similar_notes`, `find_duplicates`, `get_stats`
73
- - [Vault Operations](./docs/guide/vault-operations.md) — note CRUD, daily notes, properties, tags
74
+ - [Vault Operations](./docs/guide/vault-operations.md) — note CRUD, daily notes, properties, tags, structured queries (`query_notes`)
74
75
  - [Routing Between Tools](./docs/guide/routing.md)
75
76
  - [Configuration](./docs/guide/configuration.md) — CLI args, troubleshooting, limitations, **migration to 2.0**, development
76
77
 
package/dist/cli.d.ts CHANGED
@@ -175,15 +175,6 @@ interface TagListEntry {
175
175
  name: string;
176
176
  count: number;
177
177
  }
178
- interface GetTagInput {
179
- name: string;
180
- includeFiles?: boolean;
181
- }
182
- interface GetTagResult {
183
- name: string;
184
- count: number;
185
- files?: string[];
186
- }
187
178
  interface VaultProvider {
188
179
  createNote(input: CreateNoteInput): Promise<CreateNoteResult>;
189
180
  editNote(input: EditNoteInput): Promise<void>;
@@ -194,7 +185,6 @@ interface VaultProvider {
194
185
  removeProperty(input: RemovePropertyInput): Promise<void>;
195
186
  listProperties(): Promise<PropertyListEntry[]>;
196
187
  listTags(): Promise<TagListEntry[]>;
197
- getTag(input: GetTagInput): Promise<GetTagResult>;
198
188
  }
199
189
 
200
190
  type ExecFn = (binary: string, args: string[], options: {
@@ -228,8 +218,12 @@ interface ReadNotesInput {
228
218
  paths: string[];
229
219
  fields: ReadNotesField[];
230
220
  }
221
+ interface ScanOptions {
222
+ pathPrefix?: string;
223
+ }
231
224
  interface VaultReader {
232
225
  readNotes(input: ReadNotesInput): Promise<ReadNotesItem[]>;
226
+ scan(opts?: ScanOptions): Promise<string[]>;
233
227
  }
234
228
 
235
229
  interface OperationsModuleDeps {
package/dist/cli.js CHANGED
@@ -15014,46 +15014,6 @@ var ObsidianCLIProvider = class {
15014
15014
  const { stdout } = await this.runCommand("tags", ["counts", "sort=count", "format=json"]);
15015
15015
  return this.parseJsonList(stdout, "tags");
15016
15016
  }
15017
- async getTag(input) {
15018
- const includeFiles = input.includeFiles !== false;
15019
- const flag = includeFiles ? "verbose" : "total";
15020
- const { stdout } = await this.runCommand("tag", [`name=${input.name}`, flag]);
15021
- const COUNT_RE = /(\d+)\s*$/;
15022
- if (includeFiles) {
15023
- const lines = stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
15024
- const match = lines[0]?.match(COUNT_RE);
15025
- if (!match) {
15026
- throw new ToolHandlerError(
15027
- "CLI_ERROR",
15028
- `Could not parse tag output for '${input.name}': expected count on first line`,
15029
- { details: { name: input.name, stdout: stdout.slice(0, 500) } }
15030
- );
15031
- }
15032
- const count2 = Number(match[1]);
15033
- const files = lines.slice(1);
15034
- if (count2 === 0) {
15035
- throw new ToolHandlerError("TAG_NOT_FOUND", `Tag not found: ${input.name}`, {
15036
- details: { name: input.name }
15037
- });
15038
- }
15039
- return { name: input.name, count: count2, files };
15040
- }
15041
- const totalMatch = stdout.trim().match(COUNT_RE);
15042
- if (!totalMatch) {
15043
- throw new ToolHandlerError(
15044
- "CLI_ERROR",
15045
- `Could not parse tag total for '${input.name}': expected numeric output`,
15046
- { details: { name: input.name, stdout: stdout.slice(0, 500) } }
15047
- );
15048
- }
15049
- const count = Number(totalMatch[1]);
15050
- if (count === 0) {
15051
- throw new ToolHandlerError("TAG_NOT_FOUND", `Tag not found: ${input.name}`, {
15052
- details: { name: input.name }
15053
- });
15054
- }
15055
- return { name: input.name, count };
15056
- }
15057
15017
  // Best-effort: a `text` property whose value happens to be "true" or "42"
15058
15018
  // will be coerced to boolean/number. Callers needing ground-truth types should
15059
15019
  // use read_notes and parse frontmatter directly.
@@ -15133,12 +15093,6 @@ var ObsidianCLIProvider = class {
15133
15093
  { details: { stderr, command }, cause: error48 }
15134
15094
  );
15135
15095
  }
15136
- if (command === "tag" && /tag not found/i.test(stderr)) {
15137
- return new ToolHandlerError("TAG_NOT_FOUND", `Tag not found: ${stderr.trim()}`, {
15138
- details: { stderr },
15139
- cause: error48
15140
- });
15141
- }
15142
15096
  if (/not found/i.test(stderr)) {
15143
15097
  return new ToolHandlerError("NOT_FOUND", `Note not found: ${stderr.trim() || "unknown"}`, {
15144
15098
  details: { stderr, command },
@@ -15158,8 +15112,353 @@ var ObsidianCLIProvider = class {
15158
15112
  }
15159
15113
  };
15160
15114
 
15161
- // src/modules/operations/tool-handlers.ts
15115
+ // src/modules/operations/query/query-notes.ts
15116
+ import siftModule from "sift";
15117
+
15118
+ // src/modules/operations/vault-reader.ts
15119
+ import { readFile as fsReadFile, stat as fsStat } from "fs/promises";
15120
+ import path5 from "path";
15121
+ import fastGlob from "fast-glob";
15122
+ var ScanPathNotFoundError = class extends Error {
15123
+ code = "PATH_NOT_FOUND";
15124
+ constructor(prefix) {
15125
+ super(`path_prefix not found: ${prefix}`);
15126
+ this.name = "ScanPathNotFoundError";
15127
+ }
15128
+ };
15129
+ var FsVaultReader = class {
15130
+ vaultRoot;
15131
+ readFile;
15132
+ stat;
15133
+ glob;
15134
+ constructor(opts) {
15135
+ this.vaultRoot = opts.vaultRoot;
15136
+ this.readFile = opts.readFile ?? ((p, enc) => fsReadFile(p, enc));
15137
+ this.stat = opts.stat ?? ((p) => fsStat(p));
15138
+ this.glob = opts.glob ?? ((pattern, options) => fastGlob(pattern, { ...options }));
15139
+ }
15140
+ async readNotes(input) {
15141
+ return Promise.all(input.paths.map((p) => this.readOne(p)));
15142
+ }
15143
+ async scan(opts = {}) {
15144
+ const prefix = normalizeScanPrefix(opts.pathPrefix);
15145
+ if (prefix) {
15146
+ const absPrefix = path5.join(this.vaultRoot, prefix);
15147
+ try {
15148
+ const stats = await this.stat(absPrefix);
15149
+ if (!stats.isDirectory()) {
15150
+ throw new ScanPathNotFoundError(prefix);
15151
+ }
15152
+ } catch (err) {
15153
+ if (err instanceof ScanPathNotFoundError) throw err;
15154
+ const code = err.code;
15155
+ if (code === "ENOENT" || code === "ENOTDIR") {
15156
+ throw new ScanPathNotFoundError(prefix);
15157
+ }
15158
+ throw err;
15159
+ }
15160
+ }
15161
+ const cwd = prefix ? path5.join(this.vaultRoot, prefix) : this.vaultRoot;
15162
+ const matches = await this.glob("**/*.md", {
15163
+ cwd,
15164
+ onlyFiles: true,
15165
+ dot: false,
15166
+ followSymbolicLinks: false
15167
+ });
15168
+ if (!prefix) {
15169
+ return matches.map(toPosix).sort();
15170
+ }
15171
+ return matches.map((m) => `${prefix}/${toPosix(m)}`).sort();
15172
+ }
15173
+ async readOne(vaultRelativePath) {
15174
+ const absPath = path5.join(this.vaultRoot, vaultRelativePath);
15175
+ let raw;
15176
+ try {
15177
+ raw = await this.readFile(absPath, "utf8");
15178
+ } catch (err) {
15179
+ const code = err.code;
15180
+ if (code === "ENOENT") {
15181
+ return {
15182
+ path: vaultRelativePath,
15183
+ error: {
15184
+ code: "NOT_FOUND",
15185
+ message: `Note not found: ${vaultRelativePath}`
15186
+ }
15187
+ };
15188
+ }
15189
+ const message = err instanceof Error ? err.message : String(err);
15190
+ return {
15191
+ path: vaultRelativePath,
15192
+ error: {
15193
+ code: "READ_FAILED",
15194
+ message: `Failed to read ${vaultRelativePath}: ${message}`
15195
+ }
15196
+ };
15197
+ }
15198
+ const { frontmatter, content } = splitFrontmatter(raw);
15199
+ return { path: vaultRelativePath, frontmatter, content };
15200
+ }
15201
+ };
15202
+ function toPosix(p) {
15203
+ return p.replace(/\\/g, "/");
15204
+ }
15205
+ function normalizeScanPrefix(raw) {
15206
+ if (raw === void 0) return "";
15207
+ const trimmed = raw.trim();
15208
+ if (trimmed === "" || trimmed === "." || trimmed === "./") return "";
15209
+ const slashed = trimmed.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
15210
+ return slashed;
15211
+ }
15212
+
15213
+ // src/modules/operations/query/note-record.ts
15214
+ function toNoteRecord(item) {
15215
+ const frontmatter = item.frontmatter && typeof item.frontmatter === "object" ? item.frontmatter : {};
15216
+ return {
15217
+ path: item.path,
15218
+ frontmatter,
15219
+ tags: extractTags(frontmatter)
15220
+ };
15221
+ }
15222
+ function extractTags(frontmatter) {
15223
+ const raw = frontmatter["tags"];
15224
+ if (raw === void 0 || raw === null) return [];
15225
+ const list = Array.isArray(raw) ? raw : [raw];
15226
+ const out = [];
15227
+ for (const entry of list) {
15228
+ if (entry === null || entry === void 0) continue;
15229
+ const stringified = String(entry).trim().replace(/^#+/, "").trim();
15230
+ if (stringified === "") continue;
15231
+ out.push(stringified);
15232
+ }
15233
+ return out;
15234
+ }
15235
+
15236
+ // src/modules/operations/query/whitelist.ts
15237
+ var ALLOWED_OPERATORS = /* @__PURE__ */ new Set([
15238
+ "$eq",
15239
+ "$ne",
15240
+ "$in",
15241
+ "$nin",
15242
+ "$gt",
15243
+ "$gte",
15244
+ "$lt",
15245
+ "$lte",
15246
+ "$exists",
15247
+ "$regex",
15248
+ "$and",
15249
+ "$or",
15250
+ "$nor",
15251
+ "$not"
15252
+ ]);
15253
+ function validateFilter(filter) {
15254
+ if (!isPlainObject2(filter)) {
15255
+ throw invalidFilter("filter must be a JSON object");
15256
+ }
15257
+ walk(filter, []);
15258
+ }
15259
+ function walk(value, pathSegments) {
15260
+ if (Array.isArray(value)) {
15261
+ value.forEach((entry, idx) => walk(entry, [...pathSegments, `[${idx}]`]));
15262
+ return;
15263
+ }
15264
+ if (!isPlainObject2(value)) return;
15265
+ for (const [key, child] of Object.entries(value)) {
15266
+ if (key.startsWith("$") && !ALLOWED_OPERATORS.has(key)) {
15267
+ const where = pathSegments.length === 0 ? key : `${pathSegments.join(".")}.${key}`;
15268
+ throw invalidFilter(`operator ${key} is not allowed (at ${where})`);
15269
+ }
15270
+ walk(child, [...pathSegments, key]);
15271
+ }
15272
+ }
15273
+ function isPlainObject2(value) {
15274
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15275
+ }
15276
+ function invalidFilter(message) {
15277
+ return new ToolHandlerError("INVALID_FILTER", message);
15278
+ }
15279
+
15280
+ // src/modules/operations/query/query-notes.ts
15281
+ var sift = typeof siftModule === "function" ? siftModule : siftModule.default;
15282
+ var DEFAULT_LIMIT = 100;
15283
+ var HARD_LIMIT_CAP = 1e3;
15162
15284
  var WINDOWS_ABSOLUTE_PATH_RE2 = /^[A-Za-z]:[\\/]/;
15285
+ var READ_BATCH_SIZE = 32;
15286
+ async function runQueryNotes(input, reader) {
15287
+ const validated = validateInput(input);
15288
+ validateFilter(validated.filter);
15289
+ let paths;
15290
+ try {
15291
+ paths = await reader.scan({ pathPrefix: validated.pathPrefix });
15292
+ } catch (err) {
15293
+ if (err instanceof ScanPathNotFoundError) {
15294
+ throw new ToolHandlerError("PATH_NOT_FOUND", err.message, {
15295
+ details: { path_prefix: validated.pathPrefix }
15296
+ });
15297
+ }
15298
+ throw err;
15299
+ }
15300
+ if (paths.length === 0) {
15301
+ return { results: [], count: 0, truncated: false };
15302
+ }
15303
+ let matcher;
15304
+ try {
15305
+ matcher = sift(validated.filter);
15306
+ } catch (err) {
15307
+ const message = err instanceof Error ? err.message : String(err);
15308
+ throw new ToolHandlerError("INVALID_FILTER", `filter rejected by sift: ${message}`);
15309
+ }
15310
+ const fields = validated.includeContent ? ["frontmatter", "content"] : ["frontmatter"];
15311
+ const earlyExitAllowed = !validated.sort || validated.sort.field === "path" && validated.sort.order === "asc";
15312
+ const matched = [];
15313
+ for (let i = 0; i < paths.length; i += READ_BATCH_SIZE) {
15314
+ const slice = paths.slice(i, i + READ_BATCH_SIZE);
15315
+ const items = await reader.readNotes({ paths: slice, fields: [...fields] });
15316
+ for (const item of items) {
15317
+ if ("error" in item) {
15318
+ if (item.error.code === "READ_FAILED") {
15319
+ process.stderr.write(`[neuro-vault] query_notes: ${item.error.message}
15320
+ `);
15321
+ }
15322
+ continue;
15323
+ }
15324
+ const record2 = toNoteRecord(item);
15325
+ let isMatch;
15326
+ try {
15327
+ isMatch = matcher(record2);
15328
+ } catch (err) {
15329
+ const message = err instanceof Error ? err.message : String(err);
15330
+ throw new ToolHandlerError("INVALID_FILTER", `filter execution failed: ${message}`);
15331
+ }
15332
+ if (!isMatch) continue;
15333
+ matched.push(validated.includeContent ? { record: record2, content: item.content } : { record: record2 });
15334
+ }
15335
+ if (earlyExitAllowed && matched.length > validated.limit) break;
15336
+ }
15337
+ if (validated.sort) {
15338
+ sortInPlace(matched, validated.sort);
15339
+ }
15340
+ const truncated = matched.length > validated.limit;
15341
+ const sliced = matched.slice(0, validated.limit);
15342
+ const results = sliced.map(
15343
+ (row) => validated.includeContent ? {
15344
+ path: row.record.path,
15345
+ frontmatter: row.record.frontmatter,
15346
+ content: row.content
15347
+ } : { path: row.record.path, frontmatter: row.record.frontmatter }
15348
+ );
15349
+ return { results, count: results.length, truncated };
15350
+ }
15351
+ function validateInput(input) {
15352
+ if (!input || typeof input !== "object") {
15353
+ throw invalidParams("input must be an object", "input");
15354
+ }
15355
+ if (input.filter === void 0 || input.filter === null) {
15356
+ throw invalidParams("filter is required", "filter");
15357
+ }
15358
+ if (typeof input.filter !== "object" || Array.isArray(input.filter)) {
15359
+ throw invalidParams("filter must be a JSON object", "filter");
15360
+ }
15361
+ let pathPrefix;
15362
+ if (input.path_prefix !== void 0) {
15363
+ if (typeof input.path_prefix !== "string") {
15364
+ throw invalidParams("path_prefix must be a string", "path_prefix");
15365
+ }
15366
+ pathPrefix = normalizeScanPrefixInput(input.path_prefix);
15367
+ }
15368
+ let sort;
15369
+ if (input.sort !== void 0) {
15370
+ sort = validateSort(input.sort);
15371
+ }
15372
+ let limit = DEFAULT_LIMIT;
15373
+ if (input.limit !== void 0) {
15374
+ if (typeof input.limit !== "number" || !Number.isInteger(input.limit) || input.limit < 1 || input.limit > HARD_LIMIT_CAP) {
15375
+ throw invalidParams(`limit must be an integer between 1 and ${HARD_LIMIT_CAP}`, "limit");
15376
+ }
15377
+ limit = input.limit;
15378
+ }
15379
+ let includeContent = false;
15380
+ if (input.include_content !== void 0) {
15381
+ if (typeof input.include_content !== "boolean") {
15382
+ throw invalidParams("include_content must be a boolean", "include_content");
15383
+ }
15384
+ includeContent = input.include_content;
15385
+ }
15386
+ return {
15387
+ filter: input.filter,
15388
+ pathPrefix,
15389
+ sort,
15390
+ limit,
15391
+ includeContent
15392
+ };
15393
+ }
15394
+ function normalizeScanPrefixInput(raw) {
15395
+ const trimmed = raw.trim();
15396
+ if (trimmed === "" || trimmed === "." || trimmed === "./") return void 0;
15397
+ if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_PATH_RE2.test(trimmed)) {
15398
+ throw invalidParams("path_prefix must be vault-relative", "path_prefix");
15399
+ }
15400
+ const slashed = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
15401
+ if (slashed.split("/").some((segment) => segment === "..")) {
15402
+ throw invalidParams("path_prefix must be vault-relative", "path_prefix");
15403
+ }
15404
+ return slashed.replace(/\/+$/, "");
15405
+ }
15406
+ function validateSort(raw) {
15407
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
15408
+ throw invalidParams("sort must be an object { field, order }", "sort");
15409
+ }
15410
+ const obj = raw;
15411
+ if (typeof obj.field !== "string" || obj.field.trim() === "") {
15412
+ throw invalidParams("sort.field must be a non-empty string", "sort.field");
15413
+ }
15414
+ if (obj.field !== "path" && !obj.field.startsWith("frontmatter.")) {
15415
+ throw invalidParams('sort.field must be "path" or start with "frontmatter."', "sort.field");
15416
+ }
15417
+ if (obj.order !== "asc" && obj.order !== "desc") {
15418
+ throw invalidParams('sort.order must be "asc" or "desc"', "sort.order");
15419
+ }
15420
+ return { field: obj.field, order: obj.order };
15421
+ }
15422
+ function sortInPlace(rows, sort) {
15423
+ const direction = sort.order === "asc" ? 1 : -1;
15424
+ rows.sort((a, b) => {
15425
+ const av = readField(a.record, sort.field);
15426
+ const bv = readField(b.record, sort.field);
15427
+ return compare(av, bv) * direction;
15428
+ });
15429
+ }
15430
+ function readField(record2, field) {
15431
+ if (field === "path") return record2.path;
15432
+ const segments = field.split(".").slice(1);
15433
+ let cursor = record2.frontmatter;
15434
+ for (const seg of segments) {
15435
+ if (cursor === null || cursor === void 0) return void 0;
15436
+ if (typeof cursor !== "object") return void 0;
15437
+ cursor = cursor[seg];
15438
+ }
15439
+ return cursor;
15440
+ }
15441
+ function compare(a, b) {
15442
+ if (a === void 0 || a === null) {
15443
+ return b === void 0 || b === null ? 0 : 1;
15444
+ }
15445
+ if (b === void 0 || b === null) return -1;
15446
+ if (typeof a === "number" && typeof b === "number") {
15447
+ return a - b;
15448
+ }
15449
+ if (typeof a === "boolean" && typeof b === "boolean") {
15450
+ return a === b ? 0 : a ? 1 : -1;
15451
+ }
15452
+ const sa = String(a);
15453
+ const sb = String(b);
15454
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
15455
+ }
15456
+ function invalidParams(message, field) {
15457
+ return new ToolHandlerError("INVALID_PARAMS", message, { details: { field } });
15458
+ }
15459
+
15460
+ // src/modules/operations/tool-handlers.ts
15461
+ var WINDOWS_ABSOLUTE_PATH_RE3 = /^[A-Za-z]:[\\/]/;
15163
15462
  function invalidArgument(message, field) {
15164
15463
  return new ToolHandlerError("INVALID_ARGUMENT", message, {
15165
15464
  details: { field }
@@ -15246,7 +15545,7 @@ function normalizePath(raw) {
15246
15545
  const trimmed = raw.trim();
15247
15546
  if (!trimmed) throw invalidArgument("path must not be empty", "path");
15248
15547
  const slashed = trimmed.replace(/\\/g, "/");
15249
- if (slashed.startsWith("/") || WINDOWS_ABSOLUTE_PATH_RE2.test(slashed)) {
15548
+ if (slashed.startsWith("/") || WINDOWS_ABSOLUTE_PATH_RE3.test(slashed)) {
15250
15549
  throw invalidArgument("path must be vault-relative", "path");
15251
15550
  }
15252
15551
  if (slashed.split("/").some((segment) => segment === "..")) {
@@ -15332,6 +15631,9 @@ function createOperationsHandlers(deps) {
15332
15631
  const errors = results.reduce((n, r) => n + ("error" in r ? 1 : 0), 0);
15333
15632
  return { results, count: results.length, errors };
15334
15633
  },
15634
+ async queryNotes(input) {
15635
+ return runQueryNotes(input, reader);
15636
+ },
15335
15637
  async createNote(input) {
15336
15638
  if (input.name === void 0 && input.path === void 0) {
15337
15639
  throw invalidArgument("Provide name or path", "name");
@@ -15404,14 +15706,6 @@ function createOperationsHandlers(deps) {
15404
15706
  },
15405
15707
  async listTags(_input) {
15406
15708
  return provider.listTags();
15407
- },
15408
- async getTag(input) {
15409
- const stripped = (input.tag ?? "").trim().replace(/^#/, "").trim();
15410
- if (stripped === "") {
15411
- throw invalidArgument("tag must not be empty", "tag");
15412
- }
15413
- const includeFiles = input.include_files !== false;
15414
- return provider.getTag({ name: stripped, includeFiles });
15415
15709
  }
15416
15710
  };
15417
15711
  }
@@ -15426,6 +15720,17 @@ var readNotesSchema = external_exports.object({
15426
15720
  paths: external_exports.array(external_exports.string()).min(1).max(50),
15427
15721
  fields: external_exports.array(readNotesFieldSchema).min(1).optional()
15428
15722
  });
15723
+ var queryNotesSortSchema = external_exports.object({
15724
+ field: external_exports.string().min(1),
15725
+ order: external_exports.enum(["asc", "desc"])
15726
+ });
15727
+ var queryNotesSchema = external_exports.object({
15728
+ filter: external_exports.record(external_exports.string(), external_exports.unknown()),
15729
+ path_prefix: external_exports.string().optional(),
15730
+ sort: queryNotesSortSchema.optional(),
15731
+ limit: external_exports.number().int().min(1).max(1e3).optional(),
15732
+ include_content: external_exports.boolean().optional()
15733
+ });
15429
15734
  var createNoteSchema = external_exports.object({
15430
15735
  ...noteIdentifierShape,
15431
15736
  content: external_exports.string().optional(),
@@ -15457,10 +15762,6 @@ var removePropertySchema = external_exports.object({
15457
15762
  });
15458
15763
  var listPropertiesSchema = external_exports.object({});
15459
15764
  var listTagsSchema = external_exports.object({});
15460
- var getTagSchema = external_exports.object({
15461
- tag: external_exports.string(),
15462
- include_files: external_exports.boolean().optional()
15463
- });
15464
15765
  function buildOperationsTools(handlers) {
15465
15766
  return [
15466
15767
  {
@@ -15472,6 +15773,15 @@ function buildOperationsTools(handlers) {
15472
15773
  },
15473
15774
  handler: async (args) => invokeTool(() => handlers.readNotes(readNotesSchema.parse(args)))
15474
15775
  },
15776
+ {
15777
+ name: "query_notes",
15778
+ spec: {
15779
+ title: "Query Notes",
15780
+ description: 'Run a structured MongoDB-style query against the vault\'s frontmatter and tags. `filter` is a sift/MongoDB filter object evaluated against `NoteRecord` shape `{ path, frontmatter, tags }` \u2014 `tags` is an array of strings (no leading `#`) extracted from the `tags:` frontmatter field. Reference frontmatter keys with the dotted prefix `frontmatter.<key>`. Supported operators: `$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$exists`, `$regex`, `$and`, `$or`, `$nor`, `$not`. Optional `path_prefix` restricts the scan to a vault subtree (vault-relative POSIX, no leading slash). Optional `sort` is `{ field, order }` \u2014 `field` must be `"path"` or start with `"frontmatter."`. Optional `limit` defaults to 100, max 1000. Optional `include_content` (default false) \u2014 when true, each result also carries `content` (note body). Returns `{ results, count, truncated }`; `truncated` is true when more notes matched than `limit` allowed. Reads directly from disk and does not require Obsidian to be running.',
15781
+ inputSchema: queryNotesSchema
15782
+ },
15783
+ handler: async (args) => invokeTool(() => handlers.queryNotes(queryNotesSchema.parse(args)))
15784
+ },
15475
15785
  {
15476
15786
  name: "create_note",
15477
15787
  spec: {
@@ -15552,62 +15862,10 @@ function buildOperationsTools(handlers) {
15552
15862
  inputSchema: listTagsSchema
15553
15863
  },
15554
15864
  handler: async () => invokeTool(() => handlers.listTags({}))
15555
- },
15556
- {
15557
- name: "get_tag",
15558
- spec: {
15559
- title: "Get Tag",
15560
- description: "Get info about one tag. Provide `tag` (with or without the leading `#` \u2014 both `ai` and `#ai` work). Returns `{ name, count }` where `name` is the stripped tag string, plus `files: string[]` by default. Pass `include_files: false` for popular tags where the file list would be large.",
15561
- inputSchema: getTagSchema
15562
- },
15563
- handler: async (args) => invokeTool(() => handlers.getTag(getTagSchema.parse(args)))
15564
15865
  }
15565
15866
  ];
15566
15867
  }
15567
15868
 
15568
- // src/modules/operations/vault-reader.ts
15569
- import { readFile as fsReadFile } from "fs/promises";
15570
- import path5 from "path";
15571
- var FsVaultReader = class {
15572
- vaultRoot;
15573
- readFile;
15574
- constructor(opts) {
15575
- this.vaultRoot = opts.vaultRoot;
15576
- this.readFile = opts.readFile ?? ((p, enc) => fsReadFile(p, enc));
15577
- }
15578
- async readNotes(input) {
15579
- return Promise.all(input.paths.map((p) => this.readOne(p)));
15580
- }
15581
- async readOne(vaultRelativePath) {
15582
- const absPath = path5.join(this.vaultRoot, vaultRelativePath);
15583
- let raw;
15584
- try {
15585
- raw = await this.readFile(absPath, "utf8");
15586
- } catch (err) {
15587
- const code = err.code;
15588
- if (code === "ENOENT") {
15589
- return {
15590
- path: vaultRelativePath,
15591
- error: {
15592
- code: "NOT_FOUND",
15593
- message: `Note not found: ${vaultRelativePath}`
15594
- }
15595
- };
15596
- }
15597
- const message = err instanceof Error ? err.message : String(err);
15598
- return {
15599
- path: vaultRelativePath,
15600
- error: {
15601
- code: "READ_FAILED",
15602
- message: `Failed to read ${vaultRelativePath}: ${message}`
15603
- }
15604
- };
15605
- }
15606
- const { frontmatter, content } = splitFrontmatter(raw);
15607
- return { path: vaultRelativePath, frontmatter, content };
15608
- }
15609
- };
15610
-
15611
15869
  // src/modules/operations/index.ts
15612
15870
  function createOperationsModule(config2, deps = {}) {
15613
15871
  const providerFactory = deps.vaultProviderFactory ?? ((opts) => new ObsidianCLIProvider(opts));
@@ -15635,6 +15893,10 @@ Use \`read_notes\`, \`create_note\`, \`edit_note\`, \`read_daily\`, \`append_dai
15635
15893
 
15636
15894
  \`create_note\` with \`overwrite: true\` is destructive. Always ask the user before overwriting an existing note.
15637
15895
 
15896
+ ### Structured queries
15897
+
15898
+ Use \`query_notes\` for multi-criteria questions that combine tags, frontmatter properties, and ranges \u2014 for example "active projects with #ai", "todo tasks created this week", "notes with deadline set", "all notes tagged X". The \`filter\` is a MongoDB-style object evaluated against \`{ path, frontmatter, tags }\` \u2014 reference frontmatter keys as \`frontmatter.<key>\` and tags via the top-level \`tags\` field (no leading \`#\`). Supported operators: \`$eq\`, \`$ne\`, \`$in\`, \`$nin\`, \`$gt\`, \`$gte\`, \`$lt\`, \`$lte\`, \`$exists\`, \`$regex\`, \`$and\`, \`$or\`, \`$nor\`, \`$not\`. To list notes by a single tag use \`{ filter: { tags: 'X' } }\`. The result \`{ results, count, truncated }\` includes \`frontmatter\` always; pass \`include_content: true\` only when bodies are needed up-front (it can grow the response a lot). Reads directly from disk; does not need Obsidian running. \`limit\` defaults to 100 and is capped at 1000.
15899
+
15638
15900
  ### Frontmatter properties
15639
15901
 
15640
15902
  Use \`set_property\`, \`read_property\`, \`remove_property\` when the user asks to read or modify a single YAML frontmatter field (status, due date, priority, etc.). Use \`list_properties\` to see what property names are already in use across the vault \u2014 useful before introducing a new one.
@@ -15645,11 +15907,11 @@ If you need frontmatter for one or more notes, call \`read_notes\` with \`fields
15645
15907
 
15646
15908
  ### Tags
15647
15909
 
15648
- Use \`list_tags\` to see all tags ranked by frequency, and \`get_tag\` to find which files use a specific tag. Pass \`include_files: false\` to \`get_tag\` for very popular tags where the full file list would be large. The leading \`#\` on tag names is optional \u2014 both \`ai\` and \`#ai\` work.
15910
+ Use \`list_tags\` to see all tags ranked by frequency. To list the notes that carry a specific tag, call \`query_notes\` with \`{ filter: { tags: '<name>' } }\` (no leading \`#\`).
15649
15911
 
15650
15912
  ### CLI availability
15651
15913
 
15652
- The vault-operations tools (other than \`read_notes\`) route through the Obsidian CLI and require Obsidian to be running. If a call fails with \`CLI_NOT_FOUND\` or \`CLI_UNAVAILABLE\`, tell the user and stop \u2014 do not retry. \`read_notes\` reads directly from disk and does not need Obsidian to be running.
15914
+ The vault-operations tools (other than \`read_notes\` and \`query_notes\`) route through the Obsidian CLI and require Obsidian to be running. If a call fails with \`CLI_NOT_FOUND\` or \`CLI_UNAVAILABLE\`, tell the user and stop \u2014 do not retry. \`read_notes\` and \`query_notes\` read directly from disk and do not need Obsidian to be running.
15653
15915
 
15654
15916
  ## When to use semantic search
15655
15917
 
@@ -15677,7 +15939,7 @@ Use \`search_notes\` when the user is recalling a topic fuzzily, asking a concep
15677
15939
 
15678
15940
  If the user gives an exact anchor (note path, daily note, tag, frontmatter field), prefer operations tools. If the user is recalling fuzzily or asking a conceptual question, prefer \`search_notes\`. After semantic search finds a relevant note, you can read it with \`read_notes\` (passing the path in a one-element array, or batching with sibling paths) to see the details.
15679
15941
 
15680
- For tag-driven questions ("which notes are tagged X?", "show me everything in #ai") use \`get_tag\`, not \`search_notes\` \u2014 the answer is exact, not fuzzy.
15942
+ For tag-driven questions ("which notes are tagged X?", "show me everything in #ai") use \`query_notes\` with \`{ filter: { tags: '<name>' } }\`, not \`search_notes\` \u2014 the answer is exact, not fuzzy.
15681
15943
  `;
15682
15944
  function defaultServerFactory() {
15683
15945
  return new McpServer(