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 +2 -1
- package/dist/cli.d.ts +4 -10
- package/dist/cli.js +377 -115
- package/dist/cli.js.map +1 -1
- package/package.json +3 -1
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/
|
|
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("/") ||
|
|
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
|
|
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\`
|
|
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 \`
|
|
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(
|