@bitbonsai/mcpvault 0.16.1 → 0.17.1

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
@@ -258,6 +258,21 @@ Add `--read-only` after the vault path to expose only read tools. Mutating tools
258
258
 
259
259
  The CLI also accepts `--read-only true` and `--read-only=true` for configuration systems that require explicit values. Omit the option, or set it to `false`, to keep normal read/write access.
260
260
 
261
+ **Default search and listing exclusions:**
262
+
263
+ Repeat `--exclude <subtree>` to hide vault-relative folders from `search_notes` and `list_directory` in every session. Per-call `excludePaths` add to these defaults. Direct read and write tools remain available for excluded paths.
264
+
265
+ ```json
266
+ "args": [
267
+ "@bitbonsai/mcpvault@latest",
268
+ "/Users/yourname/Documents/ResearchVault",
269
+ "--exclude", "Archive",
270
+ "--exclude", "Snapshots/Old"
271
+ ]
272
+ ```
273
+
274
+ MCPVault does not read Obsidian's `.obsidian/app.json`; exclusions stay explicit in the MCP server configuration.
275
+
261
276
  **Configuration File Locations:**
262
277
 
263
278
  - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
@@ -981,6 +996,8 @@ Only grant write access to clients and conversations you trust. Use `--read-only
981
996
  ## Architecture
982
997
 
983
998
  - `server.ts` - MCP server entry point
999
+ - `src/cli.ts` - CLI option parsing for vault path, read-only mode, and default exclusions
1000
+ - `src/createServer.ts` - MCP tool registration, request routing, and discovery defaults
984
1001
  - `src/frontmatter.ts` - YAML frontmatter handling with gray-matter
985
1002
  - `src/filesystem.ts` - File operations with path validation
986
1003
  - `src/pathfilter.ts` - Directory and file filtering
package/dist/server.js CHANGED
@@ -24,7 +24,7 @@ mcpvault v${VERSION}
24
24
  Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
25
25
 
26
26
  Usage:
27
- npx @bitbonsai/mcpvault [vault-path] [--read-only[=true|false]]
27
+ npx @bitbonsai/mcpvault [vault-path] [--read-only[=true|false]] [--exclude <subtree>]...
28
28
 
29
29
  Arguments:
30
30
  [vault-path] Optional path to your Obsidian vault directory
@@ -35,12 +35,15 @@ Options:
35
35
  --help, -h Show this help message
36
36
  --read-only Expose read tools only and reject all vault mutations
37
37
  May be passed alone, with true/false, or as --read-only=true
38
+ --exclude PATH Hide a vault-relative subtree from search and directory listings
39
+ Repeat for multiple subtrees; direct read/write tools are unaffected
38
40
 
39
41
  Examples:
40
42
  npx @bitbonsai/mcpvault
41
43
  npx @bitbonsai/mcpvault ~/Documents/MyVault
42
44
  npx @bitbonsai/mcpvault ./Vault
43
45
  npx @bitbonsai/mcpvault ./Vault --read-only
46
+ npx @bitbonsai/mcpvault ./Vault --exclude Archive --exclude "Snapshots/Old"
44
47
  npx @bitbonsai/mcpvault /path/to/obsidian/vault
45
48
  npx @bitbonsai/mcpvault "/path/with spaces/Obsidian Vault"
46
49
  `);
@@ -48,11 +51,11 @@ Examples:
48
51
  }
49
52
  // Remove runtime options before joining trailing args, preserving support for
50
53
  // unquoted vault paths with spaces. When omitted, use the current directory.
51
- const { vaultPathArg, readOnly } = parseCliArgs(cliArgs);
54
+ const { vaultPathArg, readOnly, excludePaths } = parseCliArgs(cliArgs);
52
55
  const vaultPath = resolve(vaultPathArg || process.cwd());
53
56
  // Serve both the legacy handshake-based protocol and MCP 2026-07-28 from the
54
57
  // same process. The opening exchange selects the era for this connection.
55
- const serverHandle = serveStdio(() => createServer(vaultPath, { version: VERSION, readOnly }), { onerror: (error) => console.error(error) });
58
+ const serverHandle = serveStdio(() => createServer(vaultPath, { version: VERSION, readOnly, excludePaths }), { onerror: (error) => console.error(error) });
56
59
  // Exit when the client disconnects (stdin EOF) or the process is asked to
57
60
  // terminate. Hosts that don't send an MCP shutdown request otherwise leave
58
61
  // this process running forever, orphaned once stdin closes (#159).
package/dist/src/cli.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface ParsedCliArgs {
2
2
  vaultPathArg: string;
3
3
  readOnly: boolean;
4
+ excludePaths: string[];
4
5
  }
5
6
  /**
6
7
  * Parse runtime options without importing server.ts, which starts the MCP
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,aAAa,CAkC1D"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,aAAa,CAuD1D"}
package/dist/src/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export function parseCliArgs(args) {
7
7
  const pathArgs = [];
8
+ const excludePaths = [];
8
9
  let readOnly = false;
9
10
  for (let index = 0; index < args.length; index += 1) {
10
11
  const arg = args[index];
@@ -27,10 +28,28 @@ export function parseCliArgs(args) {
27
28
  readOnly = value === "true";
28
29
  continue;
29
30
  }
31
+ if (arg === "--exclude") {
32
+ const value = args[index + 1]?.trim();
33
+ if (!value || value.startsWith("--")) {
34
+ throw new Error("--exclude requires a subtree path");
35
+ }
36
+ excludePaths.push(value);
37
+ index += 1;
38
+ continue;
39
+ }
40
+ if (arg.startsWith("--exclude=")) {
41
+ const value = arg.slice("--exclude=".length).trim();
42
+ if (!value) {
43
+ throw new Error("--exclude requires a subtree path");
44
+ }
45
+ excludePaths.push(value);
46
+ continue;
47
+ }
30
48
  pathArgs.push(arg);
31
49
  }
32
50
  return {
33
51
  vaultPathArg: pathArgs.join(" ").trim(),
34
52
  readOnly,
53
+ excludePaths,
35
54
  };
36
55
  }
@@ -8,6 +8,8 @@ export interface CreateServerOptions {
8
8
  frontmatterHandler?: FrontmatterHandler;
9
9
  /** Expose read tools only and reject direct calls to mutating tools. */
10
10
  readOnly?: boolean;
11
+ /** Vault-relative subtrees hidden from search and directory listings. */
12
+ excludePaths?: string[];
11
13
  }
12
14
  export declare function createServer(vaultPath: string, options?: CreateServerOptions): Server;
13
15
  //# sourceMappingURL=createServer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,8BAA8B,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAYD,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAsezF"}
1
+ {"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,8BAA8B,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAoCD,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAofzF"}
@@ -4,7 +4,7 @@ import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
4
4
  import { PathFilter } from "./pathfilter.js";
5
5
  import { SearchService } from "./search.js";
6
6
  import { handleWikiLinkTool } from "./wikilink/index.js";
7
- import { resolve } from "path";
7
+ import { posix, resolve } from "path";
8
8
  const MUTATING_TOOLS = new Set([
9
9
  "write_note",
10
10
  "patch_note",
@@ -14,8 +14,29 @@ const MUTATING_TOOLS = new Set([
14
14
  "update_frontmatter",
15
15
  "manage_tags",
16
16
  ]);
17
+ function normalizeSubtree(path) {
18
+ let relativePath = path.trim().replace(/\\/g, "/");
19
+ while (relativePath.startsWith("/"))
20
+ relativePath = relativePath.slice(1);
21
+ const normalizedPath = posix.normalize(relativePath);
22
+ if (normalizedPath === ".")
23
+ return "";
24
+ return normalizedPath.endsWith("/") ? normalizedPath.slice(0, -1) : normalizedPath;
25
+ }
26
+ function mergeExcludePaths(defaults, requested) {
27
+ const provided = Array.isArray(requested) ? requested : [];
28
+ return [...new Set([...defaults, ...provided]
29
+ .filter((path) => typeof path === "string")
30
+ .map(normalizeSubtree)
31
+ .filter(Boolean))];
32
+ }
33
+ function isExcluded(path, excludePaths) {
34
+ const normalizedPath = normalizeSubtree(path);
35
+ return excludePaths.some((excluded) => normalizedPath === excluded || normalizedPath.startsWith(`${excluded}/`));
36
+ }
17
37
  export function createServer(vaultPath, options = {}) {
18
- const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(), frontmatterHandler = new FrontmatterHandler(), readOnly = false, } = options;
38
+ const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(), frontmatterHandler = new FrontmatterHandler(), readOnly = false, excludePaths = [], } = options;
39
+ const defaultExcludePaths = mergeExcludePaths([], excludePaths);
19
40
  const resolvedVaultPath = resolve(vaultPath);
20
41
  const fileSystem = new FileSystemService(resolvedVaultPath, pathFilter, frontmatterHandler);
21
42
  const searchService = new SearchService(resolvedVaultPath, pathFilter);
@@ -317,10 +338,17 @@ export function createServer(vaultPath, options = {}) {
317
338
  };
318
339
  }
319
340
  case "list_directory": {
320
- const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
341
+ const requestedPath = normalizeSubtree(trimmedArgs.path || "");
342
+ if (isExcluded(requestedPath, defaultExcludePaths)) {
343
+ throw new Error(`Directory excluded by server configuration: ${requestedPath}`);
344
+ }
345
+ const listing = await fileSystem.listDirectory(trimmedArgs.path || "");
346
+ const prefix = requestedPath ? `${requestedPath}/` : "";
347
+ const directories = listing.directories.filter((name) => !isExcluded(`${prefix}${name}`, defaultExcludePaths));
348
+ const files = listing.files.filter((name) => !isExcluded(`${prefix}${name}`, defaultExcludePaths));
321
349
  const indent = trimmedArgs.prettyPrint ? 2 : undefined;
322
350
  return {
323
- content: [{ type: "text", text: JSON.stringify({ dirs: listing.directories, files: listing.files }, null, indent) }]
351
+ content: [{ type: "text", text: JSON.stringify({ dirs: directories, files }, null, indent) }]
324
352
  };
325
353
  }
326
354
  case "delete_note": {
@@ -342,7 +370,7 @@ export function createServer(vaultPath, options = {}) {
342
370
  searchFrontmatter: trimmedArgs.searchFrontmatter,
343
371
  caseSensitive: trimmedArgs.caseSensitive,
344
372
  pathPrefix: trimmedArgs.pathPrefix,
345
- excludePaths: trimmedArgs.excludePaths
373
+ excludePaths: mergeExcludePaths(defaultExcludePaths, trimmedArgs.excludePaths)
346
374
  });
347
375
  const indent = trimmedArgs.prettyPrint ? 2 : undefined;
348
376
  return {
@@ -487,5 +515,8 @@ function trimPaths(args) {
487
515
  if (trimmed.paths && Array.isArray(trimmed.paths)) {
488
516
  trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
489
517
  }
518
+ if (trimmed.excludePaths && Array.isArray(trimmed.excludePaths)) {
519
+ trimmed.excludePaths = trimmed.excludePaths.map((p) => typeof p === 'string' ? p.trim() : p);
520
+ }
490
521
  return trimmed;
491
522
  }
@@ -1 +1 @@
1
- {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB,EAAE,QAAQ,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAE3V;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAiBtE;AAoBD,qBAAa,iBAAiB;IAK1B,OAAO,CAAC,SAAS;IAJnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;IAE/B,YACU,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB,EAWxC;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,WAAW;IAkDb,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+BhD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA0EtD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA4FjE;IAEK,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA4DhE;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAEK,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAchD;IAEK,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CAyGhE;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CAoF1D;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA+H1D;IAEK,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA+CzE;IAEK,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoCtE;IAEK,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CA0CvD;IAEK,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqG1E;IAED,YAAY,IAAI,MAAM,CAErB;IAED;;;;;;;;;;;;;;OAcG;IACG,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA+CjE;IAEK,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA+EzD;IAEK,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAehE;IAEK,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAuDhE;IAEK,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAiDlE;CACF"}
1
+ {"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB,EAAE,QAAQ,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAE3V;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAiBtE;AAoBD,qBAAa,iBAAiB;IAK1B,OAAO,CAAC,SAAS;IAJnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;IAE/B,YACU,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB,EAWxC;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,WAAW;IAgDb,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+BhD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA0EtD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA4FjE;IAEK,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA4DhE;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAEK,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAchD;IAEK,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CAyGhE;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA4H1D;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA+H1D;IAEK,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA+CzE;IAEK,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoCtE;IAEK,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CA0CvD;IAEK,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqG1E;IAED,YAAY,IAAI,MAAM,CAErB;IAED;;;;;;;;;;;;;;OAcG;IACG,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA+CjE;IAEK,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA+EzD;IAEK,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAehE;IAEK,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAuDhE;IAEK,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAiDlE;CACF"}
@@ -1,7 +1,7 @@
1
1
  import { join, resolve, relative, dirname } from 'path';
2
2
  import { homedir } from 'os';
3
3
  import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
4
- import { constants, realpathSync } from 'node:fs';
4
+ import { constants, lstatSync, realpathSync } from 'node:fs';
5
5
  import trash from 'trash';
6
6
  import { FrontmatterHandler } from './frontmatter.js';
7
7
  import { PathFilter } from './pathfilter.js';
@@ -104,46 +104,45 @@ export class FileSystemService {
104
104
  if (relativeToVault.startsWith('..')) {
105
105
  throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
106
106
  }
107
- // Security check: ensure symlinks don't escape vault boundary
108
- try {
109
- const realPath = realpathSync(fullPath);
110
- const realRelative = relative(this.vaultPath, realPath);
111
- if (realRelative.startsWith('..')) {
112
- throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
107
+ // Walk missing parents to the nearest existing ancestor, without following dangling links.
108
+ let candidate = fullPath;
109
+ while (true) {
110
+ try {
111
+ const realPath = realpathSync(candidate);
112
+ if (relative(this.vaultPath, realPath).startsWith('..')) {
113
+ throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
114
+ }
115
+ break;
113
116
  }
114
- }
115
- catch (err) {
116
- if (err instanceof Error && 'code' in err) {
117
- const code = err.code;
118
- if (code === 'ENOENT') {
119
- // File doesn't exist yet (e.g. writing a new note). Verify the parent directory resolves inside vault.
117
+ catch (err) {
118
+ if (!(err instanceof Error) || !('code' in err))
119
+ throw err;
120
+ if (err.code === 'ENOENT') {
120
121
  try {
121
- const parentReal = realpathSync(dirname(fullPath));
122
- const parentRelative = relative(this.vaultPath, parentReal);
123
- if (parentRelative.startsWith('..')) {
124
- throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
122
+ if (lstatSync(candidate).isSymbolicLink()) {
123
+ throw new Error(`Cannot resolve symlink: ${relativePath}. Symbolic links must point to an existing path within the vault.`);
125
124
  }
126
125
  }
127
- catch (parentErr) {
128
- // Parent doesn't exist either (will be created by mkdir). Lexical check above is sufficient.
129
- if (parentErr instanceof Error && parentErr.message.includes('outside vault')) {
130
- throw parentErr;
126
+ catch (linkError) {
127
+ if (!(linkError instanceof Error) || !('code' in linkError) || linkError.code !== 'ENOENT') {
128
+ throw linkError;
131
129
  }
132
130
  }
131
+ const parent = dirname(candidate);
132
+ if (candidate === this.vaultPath || parent === candidate)
133
+ throw err;
134
+ candidate = parent;
133
135
  }
134
- else if (code === 'ELOOP') {
136
+ else if (err.code === 'ELOOP') {
135
137
  throw new Error(`Circular symlink detected: ${relativePath}. The symbolic link chain forms a loop.`);
136
138
  }
137
- else if (code === 'EACCES') {
139
+ else if (err.code === 'EACCES' || err.code === 'EPERM') {
138
140
  throw new Error(`Permission denied resolving symlink: ${relativePath}. Cannot verify the symbolic link target is within the vault.`);
139
141
  }
140
142
  else {
141
143
  throw err;
142
144
  }
143
145
  }
144
- else {
145
- throw err;
146
- }
147
146
  }
148
147
  return fullPath;
149
148
  }
@@ -527,20 +526,43 @@ export class FileSystemService {
527
526
  }
528
527
  const oldFullPath = this.resolvePath(oldPath);
529
528
  const newFullPath = this.resolvePath(newPath);
529
+ if (oldFullPath === newFullPath) {
530
+ return {
531
+ success: false,
532
+ oldPath,
533
+ newPath,
534
+ message: `Source and target paths are the same: ${oldPath}. Please provide a different target path.`
535
+ };
536
+ }
537
+ const sourceDirectoryResult = {
538
+ success: false,
539
+ oldPath,
540
+ newPath,
541
+ message: `Source path is a directory: ${oldPath}. Please provide a file path.`
542
+ };
543
+ const targetDirectoryResult = {
544
+ success: false,
545
+ oldPath,
546
+ newPath,
547
+ message: `Target path is a directory: ${newPath}. Please provide a file path.`
548
+ };
549
+ const sourceMissingResult = {
550
+ success: false,
551
+ oldPath,
552
+ newPath,
553
+ message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
554
+ };
530
555
  try {
531
- // Read source content (will throw ENOENT if not found)
532
556
  let content;
533
557
  try {
534
558
  content = await readFile(oldFullPath, 'utf-8');
535
559
  }
536
560
  catch (error) {
537
561
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
538
- return {
539
- success: false,
540
- oldPath,
541
- newPath,
542
- message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
543
- };
562
+ return sourceMissingResult;
563
+ }
564
+ if (error instanceof Error && 'code' in error && error.code === 'EISDIR') {
565
+ return sourceDirectoryResult;
544
566
  }
545
567
  throw error;
546
568
  }
@@ -557,13 +579,29 @@ export class FileSystemService {
557
579
  }
558
580
  }
559
581
  catch (error) {
560
- if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
561
- return {
562
- success: false,
563
- oldPath,
564
- newPath,
565
- message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
566
- };
582
+ if (error instanceof Error && 'code' in error) {
583
+ if (error.code === 'EISDIR') {
584
+ return targetDirectoryResult;
585
+ }
586
+ // Inspect only after a failed write; these codes also cover Windows directories.
587
+ if (error.code === 'EEXIST' || error.code === 'EPERM' || error.code === 'EACCES') {
588
+ try {
589
+ if ((await stat(newFullPath)).isDirectory()) {
590
+ return targetDirectoryResult;
591
+ }
592
+ }
593
+ catch {
594
+ // Preserve the write error if the target changed or cannot be inspected.
595
+ }
596
+ }
597
+ if (error.code === 'EEXIST') {
598
+ return {
599
+ success: false,
600
+ oldPath,
601
+ newPath,
602
+ message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
603
+ };
604
+ }
567
605
  }
568
606
  throw error;
569
607
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbonsai/mcpvault",
3
- "version": "0.16.1",
3
+ "version": "0.17.1",
4
4
  "description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
5
5
  "homepage": "https://mcpvault.org",
6
6
  "author": "bitbonsai",