feat: extract createServer factory for library consumers (closes #84)

This commit is contained in:
Mauricio Wolff
2026-05-25 21:49:13 +02:00
parent 0ddf90f4a8
commit cf7a5fa205
37 changed files with 1267 additions and 3084 deletions
+13
View File
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.10.0] - 2026-03-20
### Added
- New `createServer(vaultPath, options?)` factory function for library consumers ([#84](https://github.com/bitbonsai/mcpvault/issues/84))
- `src/index.ts` barrel exports for all public APIs and types
- TypeScript declaration files (`.d.ts`) included in published package
- `exports`, `types` fields in `package.json` for proper ESM library consumption
### Changed
- `server.ts` slimmed to ~60-line CLI entry point, all logic moved to `src/createServer.ts`
- Test files excluded from `dist/` output
- Minimum Node version bumped to 20 (Node 18 EOL)
## [0.9.1] - 2026-03-20
### Fixed
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
export {};
//# sourceMappingURL=server.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../server.ts"],"names":[],"mappings":""}
Vendored Executable → Regular
+9 -648
View File
@@ -1,11 +1,6 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
import { FileSystemService } from "./src/filesystem.js";
import { FrontmatterHandler, parseFrontmatter } from "./src/frontmatter.js";
import { PathFilter } from "./src/pathfilter.js";
import { SearchService } from "./src/search.js";
import { createServer } from "./src/createServer.js";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join, resolve } from "path";
@@ -23,12 +18,12 @@ if (firstArg === "--version" || firstArg === "-v") {
}
if (firstArg === "--help" || firstArg === "-h") {
console.log(`
@mauricio.wolff/mcp-obsidian v${VERSION}
mcpvault v${VERSION}
Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
Usage:
npx @mauricio.wolff/mcp-obsidian [vault-path]
npx @bitbonsai/mcpvault [vault-path]
Arguments:
[vault-path] Optional path to your Obsidian vault directory
@@ -39,11 +34,11 @@ Options:
--help, -h Show this help message
Examples:
npx @mauricio.wolff/mcp-obsidian
npx @mauricio.wolff/mcp-obsidian ~/Documents/MyVault
npx @mauricio.wolff/mcp-obsidian ./Vault
npx @mauricio.wolff/mcp-obsidian /path/to/obsidian/vault
npx @mauricio.wolff/mcp-obsidian "/path/with spaces/Obsidian Vault"
npx @bitbonsai/mcpvault
npx @bitbonsai/mcpvault ~/Documents/MyVault
npx @bitbonsai/mcpvault ./Vault
npx @bitbonsai/mcpvault /path/to/obsidian/vault
npx @bitbonsai/mcpvault "/path/with spaces/Obsidian Vault"
`);
process.exit(0);
}
@@ -51,640 +46,6 @@ Examples:
// When omitted, default to current working directory.
const vaultPathArg = cliArgs.join(' ').trim();
const vaultPath = resolve(vaultPathArg || process.cwd());
// Initialize services
const pathFilter = new PathFilter();
const frontmatterHandler = new FrontmatterHandler();
const fileSystem = new FileSystemService(vaultPath, pathFilter, frontmatterHandler);
const searchService = new SearchService(vaultPath, pathFilter);
const server = new Server({
name: "mcp-obsidian",
version: VERSION
}, {
capabilities: {
tools: {},
},
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "read_note",
description: "Read a note from the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["path"]
}
},
{
name: "write_note",
description: "Write a note to the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
content: {
type: "string",
description: "Content of the note"
},
frontmatter: {
type: "object",
description: "Frontmatter object (optional)"
},
mode: {
type: "string",
enum: ["overwrite", "append", "prepend"],
description: "Write mode: 'overwrite' (default), 'append', or 'prepend'",
default: "overwrite"
}
},
required: ["path", "content"]
}
},
{
name: "patch_note",
description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
oldString: {
type: "string",
description: "The exact string to replace. Must match exactly including whitespace and line breaks."
},
newString: {
type: "string",
description: "The new string to insert in place of oldString"
},
replaceAll: {
type: "boolean",
description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.",
default: false
}
},
required: ["path", "oldString", "newString"]
}
},
{
name: "list_directory",
description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path relative to vault root (default: '/')",
default: "/"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
}
}
},
{
name: "delete_note",
description: "Delete a note from the Obsidian vault (requires confirmation)",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
confirmPath: {
type: "string",
description: "Confirmation: must exactly match the path parameter to proceed with deletion"
}
},
required: ["path", "confirmPath"]
}
},
{
name: "search_notes",
description: "Search for notes in the vault by content or frontmatter",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query text"
},
limit: {
type: "number",
description: "Maximum number of results (default: 5, max: 20)",
default: 5
},
searchContent: {
type: "boolean",
description: "Search in note content (default: true)",
default: true
},
searchFrontmatter: {
type: "boolean",
description: "Search in frontmatter (default: false)",
default: false
},
caseSensitive: {
type: "boolean",
description: "Case sensitive search (default: false)",
default: false
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["query"]
}
},
{
name: "move_note",
description: "Move or rename a note in the vault",
inputSchema: {
type: "object",
properties: {
oldPath: {
type: "string",
description: "Current path of the note"
},
newPath: {
type: "string",
description: "New path for the note"
},
overwrite: {
type: "boolean",
description: "Allow overwriting existing file (default: false)",
default: false
}
},
required: ["oldPath", "newPath"]
}
},
{
name: "move_file",
description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
inputSchema: {
type: "object",
properties: {
oldPath: {
type: "string",
description: "Current path of the file"
},
newPath: {
type: "string",
description: "New path for the file"
},
confirmOldPath: {
type: "string",
description: "Confirmation: must exactly match oldPath"
},
confirmNewPath: {
type: "string",
description: "Confirmation: must exactly match newPath"
},
overwrite: {
type: "boolean",
description: "Allow overwriting existing file (default: false)",
default: false
}
},
required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
}
},
{
name: "read_multiple_notes",
description: "Read multiple notes in a batch (max 10 files)",
inputSchema: {
type: "object",
properties: {
paths: {
type: "array",
items: { type: "string" },
description: "Array of note paths to read",
maxItems: 10
},
includeContent: {
type: "boolean",
description: "Include note content (default: true)",
default: true
},
includeFrontmatter: {
type: "boolean",
description: "Include frontmatter (default: true)",
default: true
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["paths"]
}
},
{
name: "update_frontmatter",
description: "Update frontmatter of a note without changing content",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note"
},
frontmatter: {
type: "object",
description: "Frontmatter object to update"
},
merge: {
type: "boolean",
description: "Merge with existing frontmatter (default: true)",
default: true
}
},
required: ["path", "frontmatter"]
}
},
{
name: "get_notes_info",
description: "Get metadata for notes without reading full content",
inputSchema: {
type: "object",
properties: {
paths: {
type: "array",
items: { type: "string" },
description: "Array of note paths to get info for"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["paths"]
}
},
{
name: "get_frontmatter",
description: "Extract frontmatter from a note without reading the content",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["path"]
}
},
{
name: "manage_tags",
description: "Add, remove, or list tags in a note",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
operation: {
type: "string",
enum: ["add", "remove", "list"],
description: "Operation to perform: 'add', 'remove', or 'list'"
},
tags: {
type: "array",
items: { type: "string" },
description: "Array of tags (required for 'add' and 'remove' operations)"
}
},
required: ["path", "operation"]
}
},
{
name: "get_vault_stats",
description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
inputSchema: {
type: "object",
properties: {
recentCount: {
type: "number",
description: "Number of recently modified files to return (default: 5, max: 20)",
default: 5
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
}
}
}
]
};
});
// Helper function to trim path arguments
function trimPaths(args) {
const trimmed = { ...args };
// Trim single path properties
if (trimmed.path && typeof trimmed.path === 'string') {
trimmed.path = trimmed.path.trim();
}
if (trimmed.oldPath && typeof trimmed.oldPath === 'string') {
trimmed.oldPath = trimmed.oldPath.trim();
}
if (trimmed.newPath && typeof trimmed.newPath === 'string') {
trimmed.newPath = trimmed.newPath.trim();
}
if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string') {
trimmed.confirmPath = trimmed.confirmPath.trim();
}
if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string') {
trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
}
if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string') {
trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
}
// Trim path arrays
if (trimmed.paths && Array.isArray(trimmed.paths)) {
trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
}
return trimmed;
}
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const trimmedArgs = trimPaths(args);
try {
switch (name) {
case "read_note": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
fm: note.frontmatter,
content: note.content
}, null, indent)
}
]
};
}
case "write_note": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
await fileSystem.writeNote({
path: trimmedArgs.path,
content: trimmedArgs.content,
...(fm !== undefined && { frontmatter: fm }),
mode: trimmedArgs.mode || 'overwrite'
});
return {
content: [
{
type: "text",
text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})`
}
]
};
}
case "patch_note": {
const result = await fileSystem.patchNote({
path: trimmedArgs.path,
oldString: trimmedArgs.oldString,
newString: trimmedArgs.newString,
replaceAll: trimmedArgs.replaceAll
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "list_directory": {
const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
dirs: listing.directories,
files: listing.files
}, null, indent)
}
]
};
}
case "delete_note": {
const result = await fileSystem.deleteNote({
path: trimmedArgs.path,
confirmPath: trimmedArgs.confirmPath
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "search_notes": {
const results = await searchService.search({
query: trimmedArgs.query,
limit: trimmedArgs.limit,
searchContent: trimmedArgs.searchContent,
searchFrontmatter: trimmedArgs.searchFrontmatter,
caseSensitive: trimmedArgs.caseSensitive
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, indent)
}
]
};
}
case "move_note": {
const result = await fileSystem.moveNote({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "move_file": {
const result = await fileSystem.moveFile({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
confirmOldPath: trimmedArgs.confirmOldPath,
confirmNewPath: trimmedArgs.confirmNewPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "read_multiple_notes": {
const result = await fileSystem.readMultipleNotes({
paths: trimmedArgs.paths,
includeContent: trimmedArgs.includeContent,
includeFrontmatter: trimmedArgs.includeFrontmatter
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
ok: result.successful,
err: result.failed
}, null, indent)
}
]
};
}
case "update_frontmatter": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
if (!fm) {
throw new Error('frontmatter is required');
}
await fileSystem.updateFrontmatter({
path: trimmedArgs.path,
frontmatter: fm,
merge: trimmedArgs.merge
});
return {
content: [
{
type: "text",
text: `Successfully updated frontmatter for: ${trimmedArgs.path}`
}
]
};
}
case "get_notes_info": {
const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, indent)
}
]
};
}
case "get_frontmatter": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify(note.frontmatter, null, indent)
}
]
};
}
case "manage_tags": {
const result = await fileSystem.manageTags({
path: trimmedArgs.path,
operation: trimmedArgs.operation,
tags: trimmedArgs.tags
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "get_vault_stats": {
const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
const stats = await fileSystem.getVaultStats(recentCount);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
notes: stats.totalNotes,
folders: stats.totalFolders,
size: stats.totalSize,
recent: stats.recentlyModified
}, null, indent)
}
]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
});
const server = createServer(vaultPath, { version: VERSION });
const transport = new StdioServerTransport();
await server.connect(transport);
+11
View File
@@ -0,0 +1,11 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { FrontmatterHandler } from "./frontmatter.js";
import { PathFilter } from "./pathfilter.js";
export interface CreateServerOptions {
name?: string;
version?: string;
pathFilter?: PathFilter;
frontmatterHandler?: FrontmatterHandler;
}
export declare function createServer(vaultPath: string, options?: CreateServerOptions): Server;
//# sourceMappingURL=createServer.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAMnE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAI7C,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;CACzC;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CA4XzF"}
+383
View File
@@ -0,0 +1,383 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
import { FileSystemService } from "./filesystem.js";
import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
import { PathFilter } from "./pathfilter.js";
import { SearchService } from "./search.js";
import { resolve } from "path";
export function createServer(vaultPath, options = {}) {
const { name = "mcpvault", version = "0.0.0", pathFilter = new PathFilter(), frontmatterHandler = new FrontmatterHandler(), } = options;
const resolvedVaultPath = resolve(vaultPath);
const fileSystem = new FileSystemService(resolvedVaultPath, pathFilter, frontmatterHandler);
const searchService = new SearchService(resolvedVaultPath, pathFilter);
const server = new Server({ name, version }, {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "read_note",
description: "Read a note from the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["path"]
}
},
{
name: "write_note",
description: "Write a note to the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
content: { type: "string", description: "Content of the note" },
frontmatter: { type: "object", description: "Frontmatter object (optional)" },
mode: { type: "string", enum: ["overwrite", "append", "prepend"], description: "Write mode: 'overwrite' (default), 'append', or 'prepend'", default: "overwrite" }
},
required: ["path", "content"]
}
},
{
name: "patch_note",
description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
oldString: { type: "string", description: "The exact string to replace. Must match exactly including whitespace and line breaks." },
newString: { type: "string", description: "The new string to insert in place of oldString" },
replaceAll: { type: "boolean", description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.", default: false }
},
required: ["path", "oldString", "newString"]
}
},
{
name: "list_directory",
description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path relative to vault root (default: '/')", default: "/" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
}
}
},
{
name: "delete_note",
description: "Delete a note from the Obsidian vault (requires confirmation)",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
confirmPath: { type: "string", description: "Confirmation: must exactly match the path parameter to proceed with deletion" }
},
required: ["path", "confirmPath"]
}
},
{
name: "search_notes",
description: "Search for notes in the vault by content or frontmatter",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query text" },
limit: { type: "number", description: "Maximum number of results (default: 5, max: 20)", default: 5 },
searchContent: { type: "boolean", description: "Search in note content (default: true)", default: true },
searchFrontmatter: { type: "boolean", description: "Search in frontmatter (default: false)", default: false },
caseSensitive: { type: "boolean", description: "Case sensitive search (default: false)", default: false },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["query"]
}
},
{
name: "move_note",
description: "Move or rename a note in the vault",
inputSchema: {
type: "object",
properties: {
oldPath: { type: "string", description: "Current path of the note" },
newPath: { type: "string", description: "New path for the note" },
overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
},
required: ["oldPath", "newPath"]
}
},
{
name: "move_file",
description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
inputSchema: {
type: "object",
properties: {
oldPath: { type: "string", description: "Current path of the file" },
newPath: { type: "string", description: "New path for the file" },
confirmOldPath: { type: "string", description: "Confirmation: must exactly match oldPath" },
confirmNewPath: { type: "string", description: "Confirmation: must exactly match newPath" },
overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
},
required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
}
},
{
name: "read_multiple_notes",
description: "Read multiple notes in a batch (max 10 files)",
inputSchema: {
type: "object",
properties: {
paths: { type: "array", items: { type: "string" }, description: "Array of note paths to read", maxItems: 10 },
includeContent: { type: "boolean", description: "Include note content (default: true)", default: true },
includeFrontmatter: { type: "boolean", description: "Include frontmatter (default: true)", default: true },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["paths"]
}
},
{
name: "update_frontmatter",
description: "Update frontmatter of a note without changing content",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note" },
frontmatter: { type: "object", description: "Frontmatter object to update" },
merge: { type: "boolean", description: "Merge with existing frontmatter (default: true)", default: true }
},
required: ["path", "frontmatter"]
}
},
{
name: "get_notes_info",
description: "Get metadata for notes without reading full content",
inputSchema: {
type: "object",
properties: {
paths: { type: "array", items: { type: "string" }, description: "Array of note paths to get info for" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["paths"]
}
},
{
name: "get_frontmatter",
description: "Extract frontmatter from a note without reading the content",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["path"]
}
},
{
name: "manage_tags",
description: "Add, remove, or list tags in a note",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
operation: { type: "string", enum: ["add", "remove", "list"], description: "Operation to perform: 'add', 'remove', or 'list'" },
tags: { type: "array", items: { type: "string" }, description: "Array of tags (required for 'add' and 'remove' operations)" }
},
required: ["path", "operation"]
}
},
{
name: "get_vault_stats",
description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
inputSchema: {
type: "object",
properties: {
recentCount: { type: "number", description: "Number of recently modified files to return (default: 5, max: 20)", default: 5 },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
}
}
}
]
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name: toolName, arguments: args } = request.params;
const trimmedArgs = trimPaths(args);
try {
switch (toolName) {
case "read_note": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ fm: note.frontmatter, content: note.content }, null, indent) }]
};
}
case "write_note": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
await fileSystem.writeNote({
path: trimmedArgs.path,
content: trimmedArgs.content,
...(fm !== undefined && { frontmatter: fm }),
mode: trimmedArgs.mode || 'overwrite'
});
return {
content: [{ type: "text", text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})` }]
};
}
case "patch_note": {
const result = await fileSystem.patchNote({
path: trimmedArgs.path,
oldString: trimmedArgs.oldString,
newString: trimmedArgs.newString,
replaceAll: trimmedArgs.replaceAll
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "list_directory": {
const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ dirs: listing.directories, files: listing.files }, null, indent) }]
};
}
case "delete_note": {
const result = await fileSystem.deleteNote({
path: trimmedArgs.path,
confirmPath: trimmedArgs.confirmPath
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "search_notes": {
const results = await searchService.search({
query: trimmedArgs.query,
limit: trimmedArgs.limit,
searchContent: trimmedArgs.searchContent,
searchFrontmatter: trimmedArgs.searchFrontmatter,
caseSensitive: trimmedArgs.caseSensitive
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify(results, null, indent) }]
};
}
case "move_note": {
const result = await fileSystem.moveNote({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "move_file": {
const result = await fileSystem.moveFile({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
confirmOldPath: trimmedArgs.confirmOldPath,
confirmNewPath: trimmedArgs.confirmNewPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "read_multiple_notes": {
const result = await fileSystem.readMultipleNotes({
paths: trimmedArgs.paths,
includeContent: trimmedArgs.includeContent,
includeFrontmatter: trimmedArgs.includeFrontmatter
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ ok: result.successful, err: result.failed }, null, indent) }]
};
}
case "update_frontmatter": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
if (!fm) {
throw new Error('frontmatter is required');
}
await fileSystem.updateFrontmatter({
path: trimmedArgs.path,
frontmatter: fm,
merge: trimmedArgs.merge
});
return {
content: [{ type: "text", text: `Successfully updated frontmatter for: ${trimmedArgs.path}` }]
};
}
case "get_notes_info": {
const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify(result, null, indent) }]
};
}
case "get_frontmatter": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify(note.frontmatter, null, indent) }]
};
}
case "manage_tags": {
const result = await fileSystem.manageTags({
path: trimmedArgs.path,
operation: trimmedArgs.operation,
tags: trimmedArgs.tags
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "get_vault_stats": {
const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
const stats = await fileSystem.getVaultStats(recentCount);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ notes: stats.totalNotes, folders: stats.totalFolders, size: stats.totalSize, recent: stats.recentlyModified }, null, indent) }]
};
}
default:
throw new Error(`Unknown tool: ${toolName}`);
}
}
catch (error) {
return {
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
isError: true
};
}
});
return server;
}
function trimPaths(args) {
const trimmed = { ...args };
if (trimmed.path && typeof trimmed.path === 'string')
trimmed.path = trimmed.path.trim();
if (trimmed.oldPath && typeof trimmed.oldPath === 'string')
trimmed.oldPath = trimmed.oldPath.trim();
if (trimmed.newPath && typeof trimmed.newPath === 'string')
trimmed.newPath = trimmed.newPath.trim();
if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string')
trimmed.confirmPath = trimmed.confirmPath.trim();
if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string')
trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string')
trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
if (trimmed.paths && Array.isArray(trimmed.paths)) {
trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
}
return trimmed;
}
+26
View File
@@ -0,0 +1,26 @@
import { FrontmatterHandler } from './frontmatter.js';
import { PathFilter } from './pathfilter.js';
import type { ParsedNote, DirectoryListing, NoteWriteParams, DeleteNoteParams, DeleteResult, MoveNoteParams, MoveFileParams, MoveResult, BatchReadParams, BatchReadResult, UpdateFrontmatterParams, NoteInfo, TagManagementParams, TagManagementResult, PatchNoteParams, PatchNoteResult, VaultStats } from './types.js';
export declare class FileSystemService {
private vaultPath;
private frontmatterHandler;
private pathFilter;
constructor(vaultPath: string, pathFilter?: PathFilter, frontmatterHandler?: FrontmatterHandler);
private resolvePath;
readNote(path: string): Promise<ParsedNote>;
writeNote(params: NoteWriteParams): Promise<void>;
patchNote(params: PatchNoteParams): Promise<PatchNoteResult>;
listDirectory(path?: string): Promise<DirectoryListing>;
exists(path: string): Promise<boolean>;
isDirectory(path: string): Promise<boolean>;
deleteNote(params: DeleteNoteParams): Promise<DeleteResult>;
moveNote(params: MoveNoteParams): Promise<MoveResult>;
moveFile(params: MoveFileParams): Promise<MoveResult>;
readMultipleNotes(params: BatchReadParams): Promise<BatchReadResult>;
updateFrontmatter(params: UpdateFrontmatterParams): Promise<void>;
getNotesInfo(paths: string[]): Promise<NoteInfo[]>;
manageTags(params: TagManagementParams): Promise<TagManagementResult>;
getVaultPath(): string;
getVaultStats(recentCount?: number): Promise<VaultStats>;
}
//# sourceMappingURL=filesystem.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAGA,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,MAAM,YAAY,CAAC;AAEzT,qBAAa,iBAAiB;IAK1B,OAAO,CAAC,SAAS;IAJnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;gBAGrB,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB;IAazC,OAAO,CAAC,WAAW;IA6Db,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAgC3C,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IA6EjD,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IA2F5D,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA8D3D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAetC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAe3C,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC;IAmE3D,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IAoFrD,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC;IA6HrD,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAgDpE,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BjE,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IA2ClD,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IA0F3E,YAAY,IAAI,MAAM;IAIhB,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC;CAwDlE"}
+73 -5
View File
@@ -1,6 +1,6 @@
import { join, resolve, relative, dirname } from 'path';
import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { constants, realpathSync } from 'node:fs';
import { FrontmatterHandler } from './frontmatter.js';
import { PathFilter } from './pathfilter.js';
import { generateObsidianUri } from './uri.js';
@@ -10,7 +10,14 @@ export class FileSystemService {
pathFilter;
constructor(vaultPath, pathFilter, frontmatterHandler) {
this.vaultPath = vaultPath;
this.vaultPath = resolve(vaultPath);
const resolved = resolve(vaultPath);
try {
this.vaultPath = realpathSync(resolved);
}
catch {
// Vault path doesn't exist yet or is inaccessible; fall back to lexical resolution
this.vaultPath = resolved;
}
this.pathFilter = pathFilter || new PathFilter();
this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
}
@@ -26,11 +33,52 @@ export class FileSystemService {
? relativePath.slice(1)
: relativePath;
const fullPath = resolve(join(this.vaultPath, normalizedPath));
// Security check: ensure path is within vault
// Security check: ensure path is within vault (lexical)
const relativeToVault = relative(this.vaultPath, fullPath);
if (relativeToVault.startsWith('..')) {
throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
}
// Security check: ensure symlinks don't escape vault boundary
try {
const realPath = realpathSync(fullPath);
const realRelative = relative(this.vaultPath, realPath);
if (realRelative.startsWith('..')) {
throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
}
}
catch (err) {
if (err instanceof Error && 'code' in err) {
const code = err.code;
if (code === 'ENOENT') {
// File doesn't exist yet (e.g. writing a new note). Verify the parent directory resolves inside vault.
try {
const parentReal = realpathSync(dirname(fullPath));
const parentRelative = relative(this.vaultPath, parentReal);
if (parentRelative.startsWith('..')) {
throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
}
}
catch (parentErr) {
// Parent doesn't exist either (will be created by mkdir). Lexical check above is sufficient.
if (parentErr instanceof Error && parentErr.message.includes('outside vault')) {
throw parentErr;
}
}
}
else if (code === 'ELOOP') {
throw new Error(`Circular symlink detected: ${relativePath}. The symbolic link chain forms a loop.`);
}
else if (code === 'EACCES') {
throw new Error(`Permission denied resolving symlink: ${relativePath}. Cannot verify the symbolic link target is within the vault.`);
}
else {
throw err;
}
}
else {
throw err;
}
}
return fullPath;
}
async readNote(path) {
@@ -219,13 +267,33 @@ export class FileSystemService {
if (!this.pathFilter.isAllowedForListing(entryPath)) {
continue;
}
if (entry.isDirectory()) {
if (entry.isSymbolicLink()) {
// Follow symlinks that resolve inside the vault
try {
const entryFullPath = join(fullPath, entry.name);
const realPath = realpathSync(entryFullPath);
const realRelative = relative(this.vaultPath, realPath);
if (realRelative.startsWith('..')) {
continue; // Symlink target outside vault, skip silently
}
const targetStat = await stat(entryFullPath);
if (targetStat.isDirectory()) {
directories.push(entry.name);
}
else if (targetStat.isFile()) {
files.push(entry.name);
}
}
catch {
continue; // Broken/circular/inaccessible symlink, skip silently
}
}
else if (entry.isDirectory()) {
directories.push(entry.name);
}
else if (entry.isFile()) {
files.push(entry.name);
}
// Skip other types (symlinks, etc.)
}
return {
files: files.sort(),
-946
View File
@@ -1,946 +0,0 @@
import { test, expect, beforeEach, afterEach } from "vitest";
import { FileSystemService } from "./filesystem.js";
import { PathFilter } from "./pathfilter.js";
import { writeFile, readFile, mkdir, mkdtemp, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
let testVaultPath;
let fileSystem;
beforeEach(async () => {
testVaultPath = await mkdtemp(join(tmpdir(), "mcp-obsidian-test-"));
fileSystem = new FileSystemService(testVaultPath);
});
afterEach(async () => {
try {
await rm(testVaultPath, { recursive: true });
}
catch {
// Ignore cleanup errors
}
});
// ============================================================================
// PATCH TESTS
// ============================================================================
test("patch note with single occurrence", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nThis is the old content.\n\nMore text here.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "old content",
newString: "new content",
replaceAll: false
});
expect(result.success).toBe(true);
expect(result.matchCount).toBe(1);
expect(result.message).toContain("Successfully replaced 1 occurrence");
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).toContain("new content");
expect(updatedNote.content).not.toContain("old content");
});
test("patch note with multiple occurrences requires replaceAll", async () => {
const testPath = "test-note.md";
const content = "# Test\n\nrepeat word repeat word repeat";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "repeat",
newString: "unique",
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.matchCount).toBe(3);
expect(result.message).toContain("Found 3 occurrences");
expect(result.message).toContain("Use replaceAll=true");
});
test("patch note with replaceAll replaces all occurrences", async () => {
const testPath = "test-note.md";
const content = "# Test\n\nrepeat word repeat word repeat";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "repeat",
newString: "unique",
replaceAll: true
});
expect(result.success).toBe(true);
expect(result.matchCount).toBe(3);
expect(result.message).toContain("Successfully replaced 3 occurrences");
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).not.toContain("repeat");
expect(updatedNote.content.match(/unique/g)?.length).toBe(3);
});
test("patch note fails when string not found", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nSome content here.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "non-existent string",
newString: "replacement",
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.matchCount).toBe(0);
expect(result.message).toContain("String not found");
});
test("patch note with multiline replacement", async () => {
const testPath = "test-note.md";
const content = "# Test\n\n## Section A\nOld content\nOld lines\n\n## Section B\nOther content";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "## Section A\nOld content\nOld lines",
newString: "## Section A\nNew content\nNew improved lines",
replaceAll: false
});
expect(result.success).toBe(true);
expect(result.matchCount).toBe(1);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).toContain("New content");
expect(updatedNote.content).toContain("New improved lines");
expect(updatedNote.content).not.toContain("Old content");
});
test("patch note with frontmatter preserved", async () => {
const testPath = "test-note.md";
const content = `---
title: My Note
tags: [test]
---
# Content
Old text here.`;
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "Old text here.",
newString: "New text here.",
replaceAll: false
});
expect(result.success).toBe(true);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.frontmatter.title).toBe("My Note");
expect(updatedNote.frontmatter.tags).toEqual(["test"]);
expect(updatedNote.content).toContain("New text here.");
});
test("patch note fails when oldString equals newString", async () => {
const testPath = "test-note.md";
const content = "# Test\n\nSome content";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "same",
newString: "same",
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.message).toContain("must be different");
});
test("patch note fails for filtered paths", async () => {
const testPath = ".obsidian/config.json";
const result = await fileSystem.patchNote({
path: testPath,
oldString: "old",
newString: "new",
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.message).toContain("Access denied");
});
test("patch note fails when file doesn't exist", async () => {
const testPath = "non-existent-note.md";
const result = await fileSystem.patchNote({
path: testPath,
oldString: "old",
newString: "new",
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.message).toContain("File not found");
});
test("patch note fails with empty oldString", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nSome content.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "",
newString: "new",
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.message).toMatch(/empty|filled|required/i);
});
test("patch note fails with empty newString", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nSome content.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "content",
newString: "",
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.message).toMatch(/empty|filled|required/i);
});
test("patch note fails with undefined newString", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nSome content.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "content",
newString: undefined,
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.message).toMatch(/empty|filled|required/i);
// Verify the note was NOT corrupted
const note = await fileSystem.readNote(testPath);
expect(note.content).not.toContain("undefined");
expect(note.content).toContain("Some content.");
});
test("patch note fails with null newString", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nSome content.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "content",
newString: null,
replaceAll: false
});
expect(result.success).toBe(false);
expect(result.message).toMatch(/empty|filled|required/i);
// Verify the note was NOT corrupted
const note = await fileSystem.readNote(testPath);
expect(note.content).not.toContain("null");
expect(note.content).toContain("Some content.");
});
test("writeNote rejects undefined content", async () => {
const testPath = "test-note.md";
await expect(fileSystem.writeNote({
path: testPath,
content: undefined
})).rejects.toThrow(/Content is required/);
});
test("writeNote rejects null content", async () => {
const testPath = "test-note.md";
await expect(fileSystem.writeNote({
path: testPath,
content: null
})).rejects.toThrow(/Content is required/);
});
test("writeNote append with undefined content does not corrupt note", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nOriginal content.";
await writeFile(join(testVaultPath, testPath), content);
await expect(fileSystem.writeNote({
path: testPath,
content: undefined,
mode: 'append'
})).rejects.toThrow(/Content is required/);
// Verify the note was NOT corrupted
const note = await fileSystem.readNote(testPath);
expect(note.content).not.toContain("undefined");
expect(note.content).toContain("Original content.");
});
test("patch note handles regex special characters literally", async () => {
const testPath = "test-note.md";
const content = "Price: $10.50 (special)";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "$10.50",
newString: "$15.75",
replaceAll: false
});
expect(result.success).toBe(true);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).toContain("$15.75");
expect(updatedNote.content).not.toContain("$10.50");
});
test("patch note works with fenced code blocks", async () => {
const testPath = "code-fence-test.md";
const content = "# Example\n\n```rust\nfn main() {\n println!(\"hello\");\n}\n```\n";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "println!(\"hello\");",
newString: "println!(\"hello world\");",
replaceAll: false
});
expect(result.success).toBe(true);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.originalContent).toContain("println!(\"hello world\");");
});
test("patch note works with markdown tables", async () => {
const testPath = "table-test.md";
const content = "| Tool | Status |\n|---|---|\n| patch_note | flaky |\n";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "| patch_note | flaky |",
newString: "| patch_note | stable |",
replaceAll: false
});
expect(result.success).toBe(true);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.originalContent).toContain("| patch_note | stable |");
});
test("patch note preserves tabs and spaces", async () => {
const testPath = "test-note.md";
const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "tabs",
newString: "TABS",
replaceAll: false
});
expect(result.success).toBe(true);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).toContain("Line with\tTABS");
expect(updatedNote.content).toContain("\tTabbed line");
expect(updatedNote.content).toContain(" Line with spaces");
});
test("patch note is case sensitive", async () => {
const testPath = "test-note.md";
const content = "Hello world, hello again";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "hello",
newString: "hi",
replaceAll: false
});
expect(result.success).toBe(true);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).toContain("Hello world");
expect(updatedNote.content).toContain("hi again");
});
test("patch note handles many replacements efficiently", async () => {
const testPath = "test-note.md";
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i}: replace_me`);
const content = lines.join("\n");
await writeFile(join(testVaultPath, testPath), content);
const startTime = Date.now();
const result = await fileSystem.patchNote({
path: testPath,
oldString: "replace_me",
newString: "replaced",
replaceAll: true
});
const duration = Date.now() - startTime;
expect(result.success).toBe(true);
expect(result.matchCount).toBe(100);
expect(duration).toBeLessThan(1000);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).not.toContain("replace_me");
expect(updatedNote.content.match(/replaced/g)?.length).toBe(100);
});
test("patch note works with path containing spaces", async () => {
const testPath = "folder name/note with spaces.md";
const content = "# Test Note\n\nOld content here.";
await mkdir(join(testVaultPath, "folder name"), { recursive: true });
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "Old content",
newString: "New content",
replaceAll: false
});
expect(result.success).toBe(true);
const updatedNote = await fileSystem.readNote(testPath);
expect(updatedNote.content).toContain("New content");
});
// ============================================================================
// DELETE TESTS
// ============================================================================
test("delete note with correct confirmation", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nThis is a test note to be deleted.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.deleteNote({
path: testPath,
confirmPath: testPath
});
expect(result.success).toBe(true);
expect(result.path).toBe(testPath);
expect(result.message).toContain("Successfully deleted");
expect(result.message).toContain("cannot be undone");
});
test("reject deletion with incorrect confirmation", async () => {
const testPath = "test-note.md";
const content = "# Test Note\n\nThis note should not be deleted.";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.deleteNote({
path: testPath,
confirmPath: "wrong-path.md"
});
expect(result.success).toBe(false);
expect(result.path).toBe(testPath);
expect(result.message).toContain("confirmation path does not match");
const fileStillExists = await fileSystem.exists(testPath);
expect(fileStillExists).toBe(true);
});
test("handle deletion of non-existent file", async () => {
const testPath = "non-existent.md";
const result = await fileSystem.deleteNote({
path: testPath,
confirmPath: testPath
});
expect(result.success).toBe(false);
expect(result.path).toBe(testPath);
expect(result.message).toContain("File not found");
});
test("reject deletion of filtered paths", async () => {
const testPath = ".obsidian/app.json";
const result = await fileSystem.deleteNote({
path: testPath,
confirmPath: testPath
});
expect(result.success).toBe(false);
expect(result.path).toBe(testPath);
expect(result.message).toContain("Access denied");
});
test("handle directory deletion attempt", async () => {
const testPath = "test-directory";
await mkdir(join(testVaultPath, testPath));
const result = await fileSystem.deleteNote({
path: testPath,
confirmPath: testPath
});
expect(result.success).toBe(false);
expect(result.path).toBe(testPath);
expect(result.message).toContain("is not a file");
});
test("delete note with frontmatter", async () => {
const testPath = "note-with-frontmatter.md";
const content = `---
title: Test Note
tags: [test, delete]
---
# Test Note
This note has frontmatter and should be deleted successfully.`;
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.deleteNote({
path: testPath,
confirmPath: testPath
});
expect(result.success).toBe(true);
expect(result.path).toBe(testPath);
expect(result.message).toContain("Successfully deleted");
});
// ============================================================================
// FRONTMATTER INTEGRATION TESTS
// ============================================================================
test("write_note with frontmatter", async () => {
await fileSystem.writeNote({
path: "test.md",
content: "This is test content.",
frontmatter: {
title: "Test Note",
tags: ["test", "example"],
created: "2023-01-01"
}
});
const note = await fileSystem.readNote("test.md");
expect(note.frontmatter.title).toBe("Test Note");
expect(note.frontmatter.tags).toEqual(["test", "example"]);
expect(note.frontmatter.created).toBe("2023-01-01");
expect(note.content.trim()).toBe("This is test content.");
});
test("write_note with append mode preserves frontmatter", async () => {
await fileSystem.writeNote({
path: "append-test.md",
content: "Original content.",
frontmatter: { title: "Original", status: "draft" }
});
await fileSystem.writeNote({
path: "append-test.md",
content: "\nAppended content.",
frontmatter: { updated: "2023-12-01" },
mode: "append"
});
const note = await fileSystem.readNote("append-test.md");
expect(note.frontmatter.title).toBe("Original");
expect(note.frontmatter.status).toBe("draft");
expect(note.frontmatter.updated).toBe("2023-12-01");
expect(note.content.trim()).toBe("Original content.\n\nAppended content.");
});
test("update_frontmatter merges with existing", async () => {
await fileSystem.writeNote({
path: "update-test.md",
content: "Test content.",
frontmatter: {
title: "Original Title",
tags: ["original"],
status: "draft"
}
});
await fileSystem.updateFrontmatter({
path: "update-test.md",
frontmatter: {
title: "Updated Title",
priority: "high"
},
merge: true
});
const note = await fileSystem.readNote("update-test.md");
expect(note.frontmatter.title).toBe("Updated Title");
expect(note.frontmatter.tags).toEqual(["original"]);
expect(note.frontmatter.status).toBe("draft");
expect(note.frontmatter.priority).toBe("high");
expect(note.content.trim()).toBe("Test content.");
});
test("update_frontmatter replaces when merge is false", async () => {
await fileSystem.writeNote({
path: "replace-test.md",
content: "Test content.",
frontmatter: {
title: "Original Title",
tags: ["original"],
status: "draft"
}
});
await fileSystem.updateFrontmatter({
path: "replace-test.md",
frontmatter: {
title: "New Title",
priority: "high"
},
merge: false
});
const note = await fileSystem.readNote("replace-test.md");
expect(note.frontmatter.title).toBe("New Title");
expect(note.frontmatter.priority).toBe("high");
expect(note.frontmatter.tags).toBeUndefined();
expect(note.frontmatter.status).toBeUndefined();
});
test("manage_tags add operation", async () => {
await fileSystem.writeNote({
path: "tags-add-test.md",
content: "Test content.",
frontmatter: {
title: "Test",
tags: ["existing"]
}
});
const result = await fileSystem.manageTags({
path: "tags-add-test.md",
operation: "add",
tags: ["new", "important"]
});
expect(result.success).toBe(true);
expect(result.tags).toEqual(["existing", "new", "important"]);
const note = await fileSystem.readNote("tags-add-test.md");
expect(note.frontmatter.tags).toEqual(["existing", "new", "important"]);
});
test("manage_tags remove operation", async () => {
await fileSystem.writeNote({
path: "tags-remove-test.md",
content: "Test content.",
frontmatter: {
title: "Test",
tags: ["keep", "remove1", "remove2"]
}
});
const result = await fileSystem.manageTags({
path: "tags-remove-test.md",
operation: "remove",
tags: ["remove1", "remove2"]
});
expect(result.success).toBe(true);
expect(result.tags).toEqual(["keep"]);
const note = await fileSystem.readNote("tags-remove-test.md");
expect(note.frontmatter.tags).toEqual(["keep"]);
});
test("manage_tags list operation", async () => {
await fileSystem.writeNote({
path: "tags-list-test.md",
content: "Test content with #inline-tag.",
frontmatter: {
title: "Test",
tags: ["frontmatter-tag"]
}
});
const result = await fileSystem.manageTags({
path: "tags-list-test.md",
operation: "list"
});
expect(result.success).toBe(true);
expect(result.tags).toContain("frontmatter-tag");
expect(result.tags).toContain("inline-tag");
});
test("manage_tags removes tags array when empty", async () => {
await fileSystem.writeNote({
path: "tags-empty-test.md",
content: "Test content.",
frontmatter: {
title: "Test",
tags: ["remove-me"]
}
});
await fileSystem.manageTags({
path: "tags-empty-test.md",
operation: "remove",
tags: ["remove-me"]
});
const note = await fileSystem.readNote("tags-empty-test.md");
expect(note.frontmatter.tags).toBeUndefined();
expect(note.frontmatter.title).toBe("Test");
});
test("frontmatter validation with invalid data", async () => {
await expect(fileSystem.writeNote({
path: "invalid-test.md",
content: "Test content.",
frontmatter: {
title: "Test",
invalidFunction: () => "not allowed"
}
})).rejects.toThrow(/Invalid frontmatter/);
});
test("listDirectory includes non-note files but readNote still blocks them", async () => {
const imagePath = "assets/diagram.png";
await mkdir(join(testVaultPath, "assets"), { recursive: true });
await writeFile(join(testVaultPath, imagePath), "fake-png-content");
const listing = await fileSystem.listDirectory("assets");
expect(listing.files).toContain("diagram.png");
await expect(fileSystem.readNote(imagePath)).rejects.toThrow(/Access denied/);
});
// ============================================================================
// NON-EXISTENT VAULT TESTS
// ============================================================================
test("read from non-existent vault throws error", async () => {
const nonExistentFs = new FileSystemService("/non/existent/vault/path");
await expect(nonExistentFs.readNote("test.md"))
.rejects.toThrow(/File not found|ENOENT/);
});
test("write to non-existent vault creates directories", async () => {
const tempVault = await mkdtemp(join(tmpdir(), "mcp-obsidian-new-vault-"));
const newFs = new FileSystemService(tempVault);
try {
await newFs.writeNote({
path: "new-folder/nested/note.md",
content: "Test content"
});
const note = await newFs.readNote("new-folder/nested/note.md");
expect(note.content).toContain("Test content");
}
finally {
await rm(tempVault, { recursive: true });
}
});
test("list directory in non-existent vault", async () => {
const nonExistentFs = new FileSystemService("/non/existent/vault/path");
await expect(nonExistentFs.listDirectory("/"))
.rejects.toThrow();
});
// ============================================================================
// PATH TRAVERSAL WITH SPECIAL CHARACTERS
// ============================================================================
test("path traversal attempt with encoded dots blocked", async () => {
// Path traversal should be blocked even with URL encoding
await expect(fileSystem.readNote("..%2F..%2Fetc%2Fpasswd"))
.rejects.toThrow(/Path traversal not allowed/);
});
test("path traversal with .. is blocked", async () => {
await expect(fileSystem.readNote("../outside.md"))
.rejects.toThrow(/Path traversal not allowed/);
});
test("path traversal with nested .. is blocked", async () => {
await expect(fileSystem.readNote("folder/../../outside.md"))
.rejects.toThrow(/Path traversal not allowed/);
});
test("path with regex special chars is treated literally", async () => {
const testPath = "folder (copy)/note [1].md";
const content = "# Test with special chars";
await mkdir(join(testVaultPath, "folder (copy)"), { recursive: true });
await writeFile(join(testVaultPath, testPath), content);
const note = await fileSystem.readNote(testPath);
expect(note.content).toContain("Test with special chars");
});
test("path with dollar sign works", async () => {
const testPath = "$special/price$100.md";
const content = "# Price note";
await mkdir(join(testVaultPath, "$special"), { recursive: true });
await writeFile(join(testVaultPath, testPath), content);
const note = await fileSystem.readNote(testPath);
expect(note.content).toContain("Price note");
});
test("path with plus sign works", async () => {
const testPath = "C++/notes.md";
const content = "# C++ notes";
await mkdir(join(testVaultPath, "C++"), { recursive: true });
await writeFile(join(testVaultPath, testPath), content);
const note = await fileSystem.readNote(testPath);
expect(note.content).toContain("C++ notes");
});
test("path with pipe character works", async () => {
const testPath = "choice|option.md";
const content = "# Choice note";
await writeFile(join(testVaultPath, testPath), content);
const note = await fileSystem.readNote(testPath);
expect(note.content).toContain("Choice note");
});
test("delete note with special chars in path", async () => {
const testPath = "folder (archive)/note [old].md";
const content = "# Old note";
await mkdir(join(testVaultPath, "folder (archive)"), { recursive: true });
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.deleteNote({
path: testPath,
confirmPath: testPath
});
expect(result.success).toBe(true);
});
test("move note with special chars in both paths", async () => {
const oldPath = "source (1)/note [a].md";
const newPath = "dest (2)/note [b].md";
const content = "# Moving note";
await mkdir(join(testVaultPath, "source (1)"), { recursive: true });
await mkdir(join(testVaultPath, "dest (2)"), { recursive: true });
await writeFile(join(testVaultPath, oldPath), content);
const result = await fileSystem.moveNote({
oldPath,
newPath
});
expect(result.success).toBe(true);
const note = await fileSystem.readNote(newPath);
expect(note.content).toContain("Moving note");
});
test("move_file moves binary files without corruption", async () => {
const oldPath = "attachments/original image.png";
const newPath = "assets/original image.png";
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10, 0x42]);
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
await writeFile(join(testVaultPath, oldPath), binaryContent);
const result = await fileSystem.moveFile({
oldPath,
newPath,
confirmOldPath: oldPath,
confirmNewPath: newPath
});
expect(result.success).toBe(true);
const moved = await readFile(join(testVaultPath, newPath));
expect(Buffer.compare(moved, binaryContent)).toBe(0);
await expect(readFile(join(testVaultPath, oldPath))).rejects.toMatchObject({ code: "ENOENT" });
});
test("move_file respects overwrite=false", async () => {
const oldPath = "attachments/image.png";
const newPath = "assets/image.png";
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
await mkdir(join(testVaultPath, "assets"), { recursive: true });
await writeFile(join(testVaultPath, oldPath), Buffer.from([0x01, 0x02, 0x03]));
await writeFile(join(testVaultPath, newPath), Buffer.from([0xaa, 0xbb]));
const result = await fileSystem.moveFile({
oldPath,
newPath,
confirmOldPath: oldPath,
confirmNewPath: newPath,
overwrite: false
});
expect(result.success).toBe(false);
expect(result.message).toContain("Target file already exists");
});
test("move_file overwrites existing file when overwrite=true", async () => {
const oldPath = "attachments/image.png";
const newPath = "assets/image.png";
const replacement = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
await mkdir(join(testVaultPath, "assets"), { recursive: true });
await writeFile(join(testVaultPath, oldPath), replacement);
await writeFile(join(testVaultPath, newPath), Buffer.from([0x00]));
const result = await fileSystem.moveFile({
oldPath,
newPath,
confirmOldPath: oldPath,
confirmNewPath: newPath,
overwrite: true
});
expect(result.success).toBe(true);
const moved = await readFile(join(testVaultPath, newPath));
expect(Buffer.compare(moved, replacement)).toBe(0);
});
test("move_file rejects directory sources", async () => {
await mkdir(join(testVaultPath, "attachments/folder"), { recursive: true });
const result = await fileSystem.moveFile({
oldPath: "attachments/folder",
newPath: "assets/folder",
confirmOldPath: "attachments/folder",
confirmNewPath: "assets/folder"
});
expect(result.success).toBe(false);
expect(result.message).toContain("supports files only");
});
test("move_file blocks restricted system paths", async () => {
const result = await fileSystem.moveFile({
oldPath: ".obsidian/plugins/data.json",
newPath: "assets/data.json",
confirmOldPath: ".obsidian/plugins/data.json",
confirmNewPath: "assets/data.json"
});
expect(result.success).toBe(false);
expect(result.message).toContain("Access denied");
});
test("move_file requires matching confirmation paths", async () => {
const oldPath = "attachments/check.png";
const newPath = "assets/check.png";
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
await writeFile(join(testVaultPath, oldPath), Buffer.from([0x11, 0x22]));
const result = await fileSystem.moveFile({
oldPath,
newPath,
confirmOldPath: "attachments/other.png",
confirmNewPath: newPath
});
expect(result.success).toBe(false);
expect(result.message).toContain("confirmation paths do not match");
const stillExists = await readFile(join(testVaultPath, oldPath));
expect(Buffer.compare(stillExists, Buffer.from([0x11, 0x22]))).toBe(0);
});
test("patch note with regex special chars in oldString", async () => {
const testPath = "regex-test.md";
const content = "Price: $10.50 (discount)";
await writeFile(join(testVaultPath, testPath), content);
const result = await fileSystem.patchNote({
path: testPath,
oldString: "$10.50 (discount)",
newString: "$15.00 (regular)",
replaceAll: false
});
expect(result.success).toBe(true);
const note = await fileSystem.readNote(testPath);
expect(note.content).toContain("$15.00 (regular)");
});
// Note: searchNotes is in SearchService, not FileSystemService
// Search tests with regex special chars should be in search.test.ts
// ============================================================================
// UNICODE AND INTERNATIONAL PATHS
// ============================================================================
test("handles unicode in file paths", async () => {
const testPath = "日本語/ノート.md";
const content = "# Japanese note";
await mkdir(join(testVaultPath, "日本語"), { recursive: true });
await writeFile(join(testVaultPath, testPath), content);
const note = await fileSystem.readNote(testPath);
expect(note.content).toContain("Japanese note");
});
test("handles emoji in file paths", async () => {
const testPath = "📁/🎉.md";
const content = "# Emoji note";
await mkdir(join(testVaultPath, "📁"), { recursive: true });
await writeFile(join(testVaultPath, testPath), content);
const note = await fileSystem.readNote(testPath);
expect(note.content).toContain("Emoji note");
});
// ============================================================================
// VAULT STATS TESTS
// ============================================================================
test("get vault stats with empty vault", async () => {
const stats = await fileSystem.getVaultStats();
expect(stats.totalNotes).toBe(0);
expect(stats.totalFolders).toBe(0);
expect(stats.totalSize).toBe(0);
expect(stats.recentlyModified).toHaveLength(0);
});
test("get vault stats counts notes and folders", async () => {
await mkdir(join(testVaultPath, "folder1"), { recursive: true });
await mkdir(join(testVaultPath, "folder2/nested"), { recursive: true });
await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
await writeFile(join(testVaultPath, "folder1/note2.md"), "# Note 2");
await writeFile(join(testVaultPath, "folder2/nested/note3.md"), "# Note 3");
const stats = await fileSystem.getVaultStats();
expect(stats.totalNotes).toBe(3);
expect(stats.totalFolders).toBe(3); // folder1, folder2, folder2/nested
expect(stats.totalSize).toBeGreaterThan(0);
});
test("get vault stats returns recently modified files in order", async () => {
// Create files with slight delays to ensure different modification times
await writeFile(join(testVaultPath, "old.md"), "# Old");
await new Promise(resolve => setTimeout(resolve, 10));
await writeFile(join(testVaultPath, "middle.md"), "# Middle");
await new Promise(resolve => setTimeout(resolve, 10));
await writeFile(join(testVaultPath, "recent.md"), "# Recent");
const stats = await fileSystem.getVaultStats(3);
expect(stats.recentlyModified).toHaveLength(3);
expect(stats.recentlyModified[0]?.path).toBe("recent.md");
expect(stats.recentlyModified[1]?.path).toBe("middle.md");
expect(stats.recentlyModified[2]?.path).toBe("old.md");
});
test("get vault stats respects recentCount limit", async () => {
await writeFile(join(testVaultPath, "note1.md"), "# Note 1");
await writeFile(join(testVaultPath, "note2.md"), "# Note 2");
await writeFile(join(testVaultPath, "note3.md"), "# Note 3");
const stats = await fileSystem.getVaultStats(2);
expect(stats.recentlyModified).toHaveLength(2);
});
test("get vault stats excludes filtered paths", async () => {
await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
await mkdir(join(testVaultPath, ".git"), { recursive: true });
await writeFile(join(testVaultPath, ".obsidian/config.json"), "{}");
await writeFile(join(testVaultPath, ".git/config"), "git config");
await writeFile(join(testVaultPath, "visible.md"), "# Visible");
const stats = await fileSystem.getVaultStats();
expect(stats.totalNotes).toBe(1);
expect(stats.totalFolders).toBe(0); // .obsidian and .git are filtered
expect(stats.recentlyModified.map(f => f.path)).toContain("visible.md");
expect(stats.recentlyModified.map(f => f.path)).not.toContain(".obsidian/config.json");
});
test("get vault stats excludes files matched by custom ** ignored patterns", async () => {
const customFilter = new PathFilter({
ignoredPatterns: ["ignored/**"]
});
const customFileSystem = new FileSystemService(testVaultPath, customFilter);
await mkdir(join(testVaultPath, "ignored"), { recursive: true });
await mkdir(join(testVaultPath, "ignored/nested"), { recursive: true });
await writeFile(join(testVaultPath, "ignored/something.md"), "# Disallowed 1");
await writeFile(join(testVaultPath, "ignored/nested/something.md"), "# Disallowed 2");
await writeFile(join(testVaultPath, "visible.md"), "# Visible");
const stats = await customFileSystem.getVaultStats(10);
const recentPaths = stats.recentlyModified.map(file => file.path);
expect(stats.totalNotes).toBe(1);
expect(recentPaths).toContain("visible.md");
expect(recentPaths).not.toContain("ignored/something.md");
expect(recentPaths).not.toContain("ignored/nested/something.md");
});
test("get vault stats includes notes inside directories that contain dots", async () => {
await mkdir(join(testVaultPath, "2026.03"), { recursive: true });
await writeFile(join(testVaultPath, "2026.03/nested.md"), "# Nested");
await writeFile(join(testVaultPath, "root.md"), "# Root");
const stats = await fileSystem.getVaultStats(10);
const recentPaths = stats.recentlyModified.map(file => file.path);
expect(stats.totalNotes).toBe(2);
expect(stats.totalFolders).toBe(1);
expect(recentPaths).toContain("2026.03/nested.md");
expect(recentPaths).toContain("root.md");
});
test("get vault stats calculates total size correctly", async () => {
const content1 = "# Note 1 with some content";
const content2 = "# Note 2 with more content here";
await writeFile(join(testVaultPath, "note1.md"), content1);
await writeFile(join(testVaultPath, "note2.md"), content2);
const stats = await fileSystem.getVaultStats();
const expectedSize = Buffer.byteLength(content1) + Buffer.byteLength(content2);
expect(stats.totalSize).toBe(expectedSize);
});
// ============================================================================
// ERROR MESSAGE TESTS
// ============================================================================
test("error messages include remediation suggestions for file not found", async () => {
await expect(fileSystem.readNote("nonexistent.md"))
.rejects.toThrow(/list_directory/);
});
test("error messages include remediation suggestions for access denied", async () => {
await expect(fileSystem.readNote(".obsidian/config.json"))
.rejects.toThrow(/restricted/);
});
test("error messages include remediation suggestions for path traversal", async () => {
await expect(fileSystem.readNote("../outside.md"))
.rejects.toThrow(/within the vault/);
});
+16
View File
@@ -0,0 +1,16 @@
import type { ParsedNote, FrontmatterValidationResult } from './types.js';
/**
* Parse a frontmatter value that may be a JSON string (LLM clients sometimes
* pass frontmatter as a serialized JSON string instead of an object).
* Returns undefined if the value is null/undefined, or throws if invalid.
*/
export declare function parseFrontmatter(value: any): Record<string, any> | undefined;
export declare class FrontmatterHandler {
parse(content: string): ParsedNote;
stringify(frontmatterData: Record<string, any>, content: string): string;
validate(frontmatterData: Record<string, any>): FrontmatterValidationResult;
private checkForProblematicValues;
extractFrontmatter(content: string): Record<string, any>;
updateFrontmatter(content: string, updates: Record<string, any>): string;
}
//# sourceMappingURL=frontmatter.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/frontmatter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAE1E;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAmB5E;AAED,qBAAa,kBAAkB;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAkBlC,SAAS,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAaxE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,2BAA2B;IAqB3E,OAAO,CAAC,yBAAyB;IAmDjC,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAKxD,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM;CAWzE"}
-118
View File
@@ -1,118 +0,0 @@
import { test, expect, describe } from "vitest";
import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
const handler = new FrontmatterHandler();
test("parse note with frontmatter", () => {
const content = `---
title: Test Note
tags: [test, example]
created: 2023-01-01
---
# Test Note
This is a test note with frontmatter.`;
const result = handler.parse(content);
expect(result.frontmatter.title).toBe("Test Note");
expect(result.frontmatter.tags).toEqual(["test", "example"]);
expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
});
test("parse note without frontmatter", () => {
const content = `# Test Note
This is a test note without frontmatter.`;
const result = handler.parse(content);
expect(result.frontmatter).toEqual({});
expect(result.content).toBe(content);
});
test("stringify with frontmatter", () => {
const frontmatter = {
title: "Test Note",
tags: ["test", "example"]
};
const content = "# Test Note\n\nContent here.";
const result = handler.stringify(frontmatter, content);
expect(result).toContain("---");
expect(result).toContain("title: Test Note");
expect(result).toContain("tags:");
expect(result).toContain("# Test Note");
});
test("stringify without frontmatter", () => {
const content = "# Test Note\n\nContent here.";
const result = handler.stringify({}, content);
expect(result).toBe(content);
});
test("validate valid frontmatter", () => {
const frontmatter = {
title: "Valid Title",
tags: ["tag1", "tag2"],
date: new Date("2023-01-01"),
count: 42,
enabled: true
};
const result = handler.validate(frontmatter);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test("validate invalid frontmatter with function", () => {
const frontmatter = {
title: "Invalid",
badFunction: () => "not allowed"
};
const result = handler.validate(frontmatter);
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// The specific error message may vary between YAML libraries
expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
});
test("update frontmatter in existing content", () => {
const content = `---
title: Old Title
tags: [old]
---
# Content
Some content here.`;
const updates = {
title: "New Title",
modified: "2023-12-01"
};
const result = handler.updateFrontmatter(content, updates);
expect(result).toContain("title: New Title");
expect(result).toContain("modified: '2023-12-01'");
expect(result).toContain("tags:");
expect(result).toContain("# Content");
});
describe("parseFrontmatter", () => {
test("returns undefined for null and undefined", () => {
expect(parseFrontmatter(null)).toBeUndefined();
expect(parseFrontmatter(undefined)).toBeUndefined();
});
test("passes through a plain object", () => {
const obj = { tags: ["test"], title: "Hello" };
expect(parseFrontmatter(obj)).toBe(obj);
});
test("parses a JSON string into an object", () => {
const input = '{"tags": ["test"], "title": "Hello"}';
expect(parseFrontmatter(input)).toEqual({ tags: ["test"], title: "Hello" });
});
test("parses an empty JSON object string", () => {
expect(parseFrontmatter("{}")).toEqual({});
});
test("throws for a non-JSON string", () => {
expect(() => parseFrontmatter("not json")).toThrow("frontmatter must be a JSON object");
});
test("throws for a JSON array string", () => {
expect(() => parseFrontmatter('[1, 2, 3]')).toThrow("frontmatter must be a JSON object");
});
test("throws for a JSON primitive string", () => {
expect(() => parseFrontmatter('"just a string"')).toThrow("frontmatter must be a JSON object");
});
test("throws for an array value", () => {
expect(() => parseFrontmatter([1, 2, 3])).toThrow("frontmatter must be a JSON object");
});
test("throws for a number value", () => {
expect(() => parseFrontmatter(42)).toThrow("frontmatter must be a JSON object");
});
});
+8
View File
@@ -0,0 +1,8 @@
export { createServer } from './createServer.js';
export type { CreateServerOptions } from './createServer.js';
export { FileSystemService } from './filesystem.js';
export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
export { PathFilter } from './pathfilter.js';
export { SearchService } from './search.js';
export * from './types.js';
//# sourceMappingURL=index.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,cAAc,YAAY,CAAC"}
+6
View File
@@ -0,0 +1,6 @@
export { createServer } from './createServer.js';
export { FileSystemService } from './filesystem.js';
export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
export { PathFilter } from './pathfilter.js';
export { SearchService } from './search.js';
export * from './types.js';
-168
View File
@@ -1,168 +0,0 @@
import { test, expect, beforeEach, afterEach, describe } from "vitest";
import { FileSystemService } from "./filesystem.js";
import { FrontmatterHandler } from "./frontmatter.js";
import { PathFilter } from "./pathfilter.js";
import { SearchService } from "./search.js";
import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
let testVaultPath;
let pathFilter;
let frontmatterHandler;
let fileSystem;
let searchService;
beforeEach(async () => {
testVaultPath = await mkdtemp(join(tmpdir(), "mcp-obsidian-integration-"));
// Initialize services (same as server.ts)
pathFilter = new PathFilter();
frontmatterHandler = new FrontmatterHandler();
fileSystem = new FileSystemService(testVaultPath, pathFilter, frontmatterHandler);
searchService = new SearchService(testVaultPath, pathFilter);
});
afterEach(async () => {
try {
await rm(testVaultPath, { recursive: true });
}
catch {
// Ignore cleanup errors
}
});
// ============================================================================
// INTEGRATION TESTS - END-TO-END WORKFLOW
// ============================================================================
describe("Integration: Service Layer Workflows", () => {
test("write, read, and delete note workflow", async () => {
// 1. Write a note with frontmatter
await fileSystem.writeNote({
path: "test-note.md",
content: "# Test Note\n\nThis is a test.",
frontmatter: { tags: ["test"], status: "draft" }
});
// 2. Read the note back
const note = await fileSystem.readNote("test-note.md");
expect(note.content).toContain("This is a test");
expect(note.frontmatter?.tags).toEqual(["test"]);
expect(note.frontmatter?.status).toBe("draft");
// 3. Delete the note
const deleteResult = await fileSystem.deleteNote({
path: "test-note.md",
confirmPath: "test-note.md"
});
expect(deleteResult.success).toBe(true);
});
test("search notes with special characters in filenames", async () => {
// Create notes with special characters in paths
const testCases = [
{ path: "folder (archive)/note [old].md", content: "# Old Note\n\nArchived keyword." },
{ path: "C++/notes.md", content: "# C++ Notes\n\nProgramming keyword." },
{ path: "backup.2024/important.md", content: "# Important\n\nBackup keyword." },
{ path: "price$100.md", content: "# Pricing\n\nCost keyword." }
];
// Write all test notes
for (const { path, content } of testCases) {
if (path.includes('/')) {
const dirName = path.split('/')[0];
if (dirName) {
await mkdir(join(testVaultPath, dirName), { recursive: true });
}
}
await writeFile(join(testVaultPath, path), content);
}
// Search for keyword
const results = await searchService.search({
query: "keyword",
limit: 10
});
expect(results.length).toBe(4);
// Verify paths with special characters are returned correctly
const paths = results.map((r) => r.p);
expect(paths).toContain("folder (archive)/note [old].md");
expect(paths).toContain("C++/notes.md");
});
test("write note with regex special chars in content", async () => {
const content = `# Price List
Item: Widget ($10.50)
Regex: [a-z]+ matches lowercase
Math: 2 + 2 = 4
Pattern: backup.2024/**/*.md`;
await fileSystem.writeNote({
path: "special-chars.md",
content
});
// Read back and verify exact content
const note = await fileSystem.readNote("special-chars.md");
expect(note.content).toContain("($10.50)");
expect(note.content).toContain("[a-z]+");
expect(note.content).toContain("2 + 2 = 4");
expect(note.content).toContain("backup.2024/**/*.md");
});
test("search matches note filename even without content match", async () => {
// Issue #30: notes without a heading that rely on filename for discovery
await fileSystem.writeNote({
path: "Yard.md",
content: "Some info about lawn care and gardening tips."
});
await fileSystem.writeNote({
path: "Kitchen.md",
content: "Recipes and kitchen organization."
});
// Search for "yard" — should match Yard.md by filename
const results = await searchService.search({
query: "yard",
searchContent: true,
limit: 10
});
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results.some(r => r.p === "Yard.md")).toBe(true);
// Verify filename-only match has reasonable fields
const yardResult = results.find(r => r.p === "Yard.md");
expect(yardResult.t).toBe("Yard");
expect(yardResult.mc).toBeGreaterThanOrEqual(1);
// Search for "kitchen" — should match Kitchen.md by filename
const kitchenResults = await searchService.search({
query: "kitchen",
searchContent: true,
limit: 10
});
// Should match both filename AND content (content contains "kitchen")
expect(kitchenResults.some(r => r.p === "Kitchen.md")).toBe(true);
});
test("multi-step workflow: search, read multiple, update frontmatter", async () => {
// Create several notes
for (let i = 1; i <= 3; i++) {
await fileSystem.writeNote({
path: `note-${i}.md`,
content: `# Note ${i}\n\nThis contains searchterm.`,
frontmatter: { id: i, processed: false }
});
}
// Search for notes
const searchResults = await searchService.search({
query: "searchterm",
limit: 10
});
expect(searchResults.length).toBe(3);
// Read multiple notes
const paths = searchResults.map(r => r.p);
const readResult = await fileSystem.readMultipleNotes({
paths,
includeContent: true,
includeFrontmatter: true
});
expect(readResult.successful.length).toBe(3);
// Update frontmatter on all notes
for (const path of paths) {
await fileSystem.updateFrontmatter({
path,
frontmatter: { processed: true },
merge: true
});
}
// Verify updates
for (const path of paths) {
const note = await fileSystem.readNote(path);
expect(note.frontmatter?.processed).toBe(true);
}
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { PathFilterConfig } from "./types.js";
export declare class PathFilter {
private ignoredPatterns;
private allowedExtensions;
constructor(config?: Partial<PathFilterConfig>);
private simpleGlobMatch;
isAllowed(path: string): boolean;
isAllowedForListing(path: string): boolean;
private isIgnoredPath;
private isFile;
filterPaths(paths: string[]): string[];
}
//# sourceMappingURL=pathfilter.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"pathfilter.d.ts","sourceRoot":"","sources":["../../src/pathfilter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,qBAAa,UAAU;IACrB,OAAO,CAAC,eAAe,CAAW;IAClC,OAAO,CAAC,iBAAiB,CAAW;gBAExB,MAAM,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC;IAuB9C,OAAO,CAAC,eAAe;IAkBvB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAqBhC,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAQ1C,OAAO,CAAC,aAAa;IAYrB,OAAO,CAAC,MAAM;IA0Bd,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;CAGvC"}
-264
View File
@@ -1,264 +0,0 @@
import { test, expect, describe } from "vitest";
import { PathFilter } from "./pathfilter.js";
describe("PathFilter", () => {
// ============================================================================
// BASIC FUNCTIONALITY
// ============================================================================
test("allows markdown files by default", () => {
const filter = new PathFilter();
expect(filter.isAllowed("notes/test.md")).toBe(true);
expect(filter.isAllowed("test.markdown")).toBe(true);
expect(filter.isAllowed("folder/subfolder/note.txt")).toBe(true);
});
test("blocks .obsidian directory", () => {
const filter = new PathFilter();
expect(filter.isAllowed(".obsidian")).toBe(false);
expect(filter.isAllowed(".obsidian/app.json")).toBe(false);
expect(filter.isAllowed(".obsidian/plugins/plugin/main.js")).toBe(false);
});
test("blocks .git directory", () => {
const filter = new PathFilter();
expect(filter.isAllowed(".git")).toBe(false);
expect(filter.isAllowed(".git/config")).toBe(false);
expect(filter.isAllowed(".git/objects/abc123")).toBe(false);
});
test("blocks node_modules", () => {
const filter = new PathFilter();
expect(filter.isAllowed("node_modules")).toBe(false);
expect(filter.isAllowed("node_modules/package/index.js")).toBe(false);
});
test("blocks system files", () => {
const filter = new PathFilter();
expect(filter.isAllowed(".DS_Store")).toBe(false);
expect(filter.isAllowed("Thumbs.db")).toBe(false);
});
test("blocks non-allowed extensions", () => {
const filter = new PathFilter();
expect(filter.isAllowed("script.js")).toBe(false);
expect(filter.isAllowed("data.json")).toBe(false);
expect(filter.isAllowed("image.png")).toBe(false);
});
test("allows non-note files for directory listing", () => {
const filter = new PathFilter();
expect(filter.isAllowedForListing("image.png")).toBe(true);
expect(filter.isAllowedForListing("docs/report.pdf")).toBe(true);
expect(filter.isAllowedForListing("archive/data.json")).toBe(true);
});
test("blocks restricted paths in directory listing", () => {
const filter = new PathFilter();
expect(filter.isAllowedForListing(".obsidian/app.json")).toBe(false);
expect(filter.isAllowedForListing(".git/config")).toBe(false);
expect(filter.isAllowedForListing("node_modules/pkg/index.js")).toBe(false);
expect(filter.isAllowedForListing(".DS_Store")).toBe(false);
});
// ============================================================================
// REGEX SPECIAL CHARACTERS - SECURITY TESTS
// ============================================================================
describe("regex special characters in paths", () => {
test("handles dots in filenames literally", () => {
const filter = new PathFilter();
// Dots should be literal, not regex wildcards
expect(filter.isAllowed("file.name.md")).toBe(true);
expect(filter.isAllowed("v1.0.0-notes.md")).toBe(true);
});
test("handles parentheses in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("notes/(archived)/old.md")).toBe(true);
expect(filter.isAllowed("project (copy).md")).toBe(true);
});
test("handles square brackets in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("notes/[2024]/january.md")).toBe(true);
expect(filter.isAllowed("[inbox]/task.md")).toBe(true);
});
test("handles curly braces in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("templates/{daily}.md")).toBe(true);
});
test("handles plus signs in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("C++/notes.md")).toBe(true);
expect(filter.isAllowed("topic+subtopic.md")).toBe(true);
});
test("handles question marks in paths", () => {
const filter = new PathFilter();
// Question mark is a glob wildcard, but in actual filenames should work
expect(filter.isAllowed("FAQ?.md")).toBe(true);
});
test("handles asterisks in filenames", () => {
const filter = new PathFilter();
// Asterisk in filename (rare but valid on Unix)
expect(filter.isAllowed("important*.md")).toBe(true);
expect(filter.isAllowed("file*name.md")).toBe(true);
expect(filter.isAllowed("notes/todo*.md")).toBe(true);
});
test("asterisk in custom ignored pattern works as glob", () => {
const filter = new PathFilter({
ignoredPatterns: ["temp*/**"]
});
// Pattern uses * as wildcard - should match temp, temp1, temporary, etc.
expect(filter.isAllowed("temp/file.md")).toBe(false);
expect(filter.isAllowed("temp1/file.md")).toBe(false);
expect(filter.isAllowed("temporary/file.md")).toBe(false);
// Should NOT match "atemp" (pattern starts with temp)
expect(filter.isAllowed("atemp/file.md")).toBe(true);
});
test("double asterisk ** matches nested paths", () => {
const filter = new PathFilter({
ignoredPatterns: ["archive/**"]
});
expect(filter.isAllowed("archive/old.md")).toBe(false);
expect(filter.isAllowed("archive/2024/jan/note.md")).toBe(false);
expect(filter.isAllowed("other/archive/note.md")).toBe(true);
});
test("handles pipe character in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("option|choice.md")).toBe(true);
});
test("handles caret in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("version^2.md")).toBe(true);
});
test("handles dollar sign in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("price$100.md")).toBe(true);
expect(filter.isAllowed("$HOME/notes.md")).toBe(true);
});
test("handles backslash (Windows paths)", () => {
const filter = new PathFilter();
// Backslashes should be normalized to forward slashes
expect(filter.isAllowed("folder\\subfolder\\note.md")).toBe(true);
});
});
// ============================================================================
// CUSTOM IGNORED PATTERNS WITH SPECIAL CHARS
// ============================================================================
describe("custom patterns with special characters", () => {
test("custom pattern with dots is treated literally", () => {
const filter = new PathFilter({
ignoredPatterns: ["backup.2024/**"]
});
expect(filter.isAllowed("backup.2024/notes.md")).toBe(false);
// "backup_2024" should NOT match "backup.2024" pattern
expect(filter.isAllowed("backup_2024/notes.md")).toBe(true);
});
test("custom pattern with parentheses works", () => {
const filter = new PathFilter({
ignoredPatterns: ["(archive)/**"]
});
expect(filter.isAllowed("(archive)/old.md")).toBe(false);
expect(filter.isAllowed("archive/old.md")).toBe(true);
});
test("custom pattern with brackets works", () => {
const filter = new PathFilter({
ignoredPatterns: ["[trash]/**"]
});
expect(filter.isAllowed("[trash]/deleted.md")).toBe(false);
expect(filter.isAllowed("trash/deleted.md")).toBe(true);
});
});
// ============================================================================
// PATH TRAVERSAL ATTEMPTS
// ============================================================================
describe("path traversal prevention", () => {
test("blocks obvious traversal patterns", () => {
const filter = new PathFilter({
ignoredPatterns: ["../**"]
});
expect(filter.isAllowed("../secret.md")).toBe(false);
expect(filter.isAllowed("../../etc/passwd")).toBe(false);
});
test("handles encoded traversal attempts", () => {
const filter = new PathFilter();
// These should be allowed by PathFilter (path validation is in FileSystem)
// but filter shouldn't crash on unusual characters
expect(() => filter.isAllowed("%2e%2e/secret.md")).not.toThrow();
expect(() => filter.isAllowed("..%2fnotes.md")).not.toThrow();
});
});
test("allows Obsidian first-party file types", () => {
const filter = new PathFilter();
expect(filter.isAllowed("_Bases/daily-notes.base")).toBe(true);
expect(filter.isAllowed("canvas/mindmap.canvas")).toBe(true);
});
// ============================================================================
// FILTER PATHS BATCH OPERATION
// ============================================================================
describe("filterPaths", () => {
test("filters array of paths correctly", () => {
const filter = new PathFilter();
const paths = [
"notes/valid.md",
".obsidian/config.json",
"archive/old.md",
".git/HEAD",
"readme.txt"
];
const allowed = filter.filterPaths(paths);
expect(allowed).toEqual([
"notes/valid.md",
"archive/old.md",
"readme.txt"
]);
});
test("handles empty array", () => {
const filter = new PathFilter();
expect(filter.filterPaths([])).toEqual([]);
});
test("handles array with all blocked paths", () => {
const filter = new PathFilter();
const paths = [
".obsidian/app.json",
".git/config",
"node_modules/pkg/index.js"
];
expect(filter.filterPaths(paths)).toEqual([]);
});
});
// ============================================================================
// EDGE CASES
// ============================================================================
describe("edge cases", () => {
test("handles empty path", () => {
const filter = new PathFilter();
expect(() => filter.isAllowed("")).not.toThrow();
});
test("handles path with only extension", () => {
const filter = new PathFilter();
expect(filter.isAllowed(".md")).toBe(true);
});
test("handles very long paths", () => {
const filter = new PathFilter();
const longPath = "a/".repeat(100) + "note.md";
expect(() => filter.isAllowed(longPath)).not.toThrow();
expect(filter.isAllowed(longPath)).toBe(true);
});
test("handles unicode characters in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("notes/日本語.md")).toBe(true);
expect(filter.isAllowed("émojis/🎉.md")).toBe(true);
expect(filter.isAllowed("中文/笔记.md")).toBe(true);
});
test("handles spaces in paths", () => {
const filter = new PathFilter();
expect(filter.isAllowed("my notes/important file.md")).toBe(true);
});
test("handles directories (no extension)", () => {
const filter = new PathFilter();
// Directories should be allowed (no extension check)
expect(filter.isAllowed("folder/subfolder/")).toBe(true);
expect(filter.isAllowed("notes")).toBe(true);
});
test("handles directories with dots in their names", () => {
const filter = new PathFilter();
// Folders with dots should be allowed (common pattern: "1. Project", "2.5 Notes")
expect(filter.isAllowed("1. Project")).toBe(true);
expect(filter.isAllowed("2. Archive")).toBe(true);
expect(filter.isAllowed("3.5 Research")).toBe(true);
expect(filter.isAllowed("1. Project/subfolder")).toBe(true);
expect(filter.isAllowed("1. Project/note.md")).toBe(true);
// But files in those folders should still need proper extensions
expect(filter.isAllowed("1. Project/file.js")).toBe(false);
});
});
});
+11
View File
@@ -0,0 +1,11 @@
import type { PathFilter } from './pathfilter.js';
import type { SearchParams, SearchResult } from './types.js';
export declare class SearchService {
private pathFilter;
private vaultPath;
constructor(vaultPath: string, pathFilter: PathFilter);
search(params: SearchParams): Promise<SearchResult[]>;
private findMarkdownFiles;
private rerank;
}
//# sourceMappingURL=search.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/search.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAiB,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG5E,qBAAa,aAAa;IAKtB,OAAO,CAAC,UAAU;IAJpB,OAAO,CAAC,SAAS,CAAS;gBAGxB,SAAS,EAAE,MAAM,EACT,UAAU,EAAE,UAAU;IAK1B,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;YA8J7C,iBAAiB;IAwB/B,OAAO,CAAC,MAAM;CA0Bf"}
-212
View File
@@ -1,212 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { SearchService } from "./search.js";
import { PathFilter } from "./pathfilter.js";
import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
let testVaultPath;
let searchService;
beforeEach(async () => {
testVaultPath = await mkdtemp(join(tmpdir(), "mcp-obsidian-search-"));
searchService = new SearchService(testVaultPath, new PathFilter());
});
afterEach(async () => {
try {
await rm(testVaultPath, { recursive: true });
}
catch {
// Ignore cleanup errors
}
});
// Helper to write a note directly to disk
async function writeNote(path, content) {
const fullPath = join(testVaultPath, path);
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
if (dir !== testVaultPath) {
await mkdir(dir, { recursive: true });
}
await writeFile(fullPath, content);
}
describe("SearchService", () => {
// ============================================================================
// BASIC SEARCH
// ============================================================================
test("finds notes matching a query", async () => {
await writeNote("alpha.md", "# Alpha\n\nThis note has bananas.");
await writeNote("beta.md", "# Beta\n\nThis note has oranges.");
const results = await searchService.search({ query: "bananas" });
expect(results).toHaveLength(1);
expect(results[0].p).toBe("alpha.md");
});
test("returns empty array when no matches", async () => {
await writeNote("note.md", "# Note\n\nNothing relevant here.");
const results = await searchService.search({ query: "zzzznotfound" });
expect(results).toHaveLength(0);
});
test("returns empty array for empty vault", async () => {
const results = await searchService.search({ query: "anything" });
expect(results).toHaveLength(0);
});
test("throws on empty query", async () => {
await expect(searchService.search({ query: "" }))
.rejects.toThrow(/empty/);
});
test("throws on whitespace-only query", async () => {
await expect(searchService.search({ query: " " }))
.rejects.toThrow(/empty/);
});
// ============================================================================
// LIMIT
// ============================================================================
test("respects limit parameter", async () => {
for (let i = 0; i < 5; i++) {
await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
}
const results = await searchService.search({ query: "keyword", limit: 2 });
expect(results).toHaveLength(2);
});
test("caps limit at 20", async () => {
for (let i = 0; i < 25; i++) {
await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
}
const results = await searchService.search({ query: "keyword", limit: 100 });
expect(results.length).toBeLessThanOrEqual(20);
});
test("defaults limit to 5", async () => {
for (let i = 0; i < 10; i++) {
await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
}
const results = await searchService.search({ query: "keyword" });
expect(results).toHaveLength(5);
});
// ============================================================================
// CASE SENSITIVITY
// ============================================================================
test("case-insensitive search by default", async () => {
await writeNote("upper.md", "# Upper\n\nBANANA is great.");
await writeNote("lower.md", "# Lower\n\nbanana is great.");
await writeNote("mixed.md", "# Mixed\n\nBanana is great.");
const results = await searchService.search({ query: "banana", limit: 10 });
expect(results).toHaveLength(3);
});
test("case-sensitive search when enabled", async () => {
await writeNote("upper.md", "# Upper\n\nBANANA is great.");
await writeNote("lower.md", "# Lower\n\nbanana is great.");
const results = await searchService.search({
query: "BANANA",
caseSensitive: true,
limit: 10
});
expect(results).toHaveLength(1);
expect(results[0].p).toBe("upper.md");
});
// ============================================================================
// FRONTMATTER SEARCH
// ============================================================================
test("excludes frontmatter from content-only search", async () => {
await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
const results = await searchService.search({
query: "uniquetag",
searchContent: true,
searchFrontmatter: false,
limit: 10
});
expect(results).toHaveLength(0);
});
test("searches frontmatter when enabled", async () => {
await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
const results = await searchService.search({
query: "uniquetag",
searchFrontmatter: true,
limit: 10
});
expect(results).toHaveLength(1);
expect(results[0].p).toBe("note.md");
});
test("searches both content and frontmatter together", async () => {
await writeNote("fm-only.md", "---\nstatus: special\n---\n\n# Note\n\nPlain body.");
await writeNote("content-only.md", "# Note\n\nThis is special content.");
const results = await searchService.search({
query: "special",
searchContent: true,
searchFrontmatter: true,
limit: 10
});
expect(results).toHaveLength(2);
});
// ============================================================================
// FILENAME MATCHING
// ============================================================================
test("matches by filename when content has no match", async () => {
await writeNote("Recipes.md", "Some unrelated content about cooking.");
const results = await searchService.search({ query: "recipes", limit: 10 });
expect(results).toHaveLength(1);
expect(results[0].p).toBe("Recipes.md");
expect(results[0].t).toBe("Recipes");
});
// ============================================================================
// MULTI-TERM SEARCH
// ============================================================================
test("multi-term search matches notes with any term", async () => {
await writeNote("cats.md", "# Cats\n\nI love cats.");
await writeNote("dogs.md", "# Dogs\n\nI love dogs.");
await writeNote("fish.md", "# Fish\n\nI love fish.");
const results = await searchService.search({ query: "cats dogs", limit: 10 });
const paths = results.map(r => r.p);
expect(paths).toContain("cats.md");
expect(paths).toContain("dogs.md");
expect(paths).not.toContain("fish.md");
});
// ============================================================================
// RANKING
// ============================================================================
test("ranks notes with more matches higher", async () => {
await writeNote("few.md", "# Few\n\napple once.");
await writeNote("many.md", "# Many\n\napple apple apple apple apple.");
const results = await searchService.search({ query: "apple", limit: 10 });
expect(results).toHaveLength(2);
expect(results[0].p).toBe("many.md");
});
// ============================================================================
// RESULT SHAPE
// ============================================================================
test("results include expected fields", async () => {
await writeNote("folder/note.md", "# My Note\n\nSome content with target word.");
const results = await searchService.search({ query: "target", limit: 10 });
expect(results).toHaveLength(1);
const r = results[0];
expect(r.p).toBe("folder/note.md");
expect(r.t).toBe("note");
expect(r.ex).toBeDefined();
expect(r.mc).toBeGreaterThanOrEqual(1);
expect(r.ln).toBeGreaterThanOrEqual(1);
expect(r.uri).toMatch(/^obsidian:\/\//);
});
test("excerpt contains context around match", async () => {
await writeNote("note.md", "# Note\n\nSome words before target some words after.");
const results = await searchService.search({ query: "target", limit: 10 });
expect(results[0].ex).toContain("target");
});
// ============================================================================
// PATH FILTERING
// ============================================================================
test("excludes notes in filtered directories", async () => {
await writeNote("visible.md", "# Visible\n\nkeyword here.");
await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
await writeFile(join(testVaultPath, ".obsidian/config.md"), "keyword here.");
const results = await searchService.search({ query: "keyword", limit: 10 });
expect(results).toHaveLength(1);
expect(results[0].p).toBe("visible.md");
});
// ============================================================================
// TRAILING SLASH IN VAULT PATH
// ============================================================================
test("vault path with trailing slash does not truncate result paths", async () => {
const trailingSlashService = new SearchService(testVaultPath + "/", new PathFilter());
await mkdir(join(testVaultPath, "sessions"), { recursive: true });
await writeNote("sessions/foo-bar.md", "# Foo Bar\n\nSome content here.");
const results = await trailingSlashService.search({ query: "foo", limit: 5 });
expect(results).toHaveLength(1);
expect(results[0].p).toBe("sessions/foo-bar.md");
});
});
+134
View File
@@ -0,0 +1,134 @@
export interface ParsedNote {
frontmatter: Record<string, any>;
content: string;
originalContent: string;
}
export interface NoteWriteParams {
path: string;
content: string;
frontmatter?: Record<string, any>;
mode?: 'overwrite' | 'append' | 'prepend';
}
export interface PatchNoteParams {
path: string;
oldString: string;
newString: string;
replaceAll?: boolean;
}
export interface PatchNoteResult {
success: boolean;
path: string;
message: string;
matchCount?: number;
}
export interface DeleteNoteParams {
path: string;
confirmPath: string;
}
export interface DeleteResult {
success: boolean;
path: string;
message: string;
}
export interface DirectoryListing {
files: string[];
directories: string[];
}
export interface FrontmatterValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}
export interface PathFilterConfig {
ignoredPatterns: string[];
allowedExtensions: string[];
}
export interface SearchParams {
query: string;
limit?: number;
searchContent?: boolean;
searchFrontmatter?: boolean;
caseSensitive?: boolean;
}
export interface SearchResult {
p: string;
t: string;
ex: string;
mc: number;
ln?: number;
uri?: string;
}
export interface RankCandidate {
result: SearchResult;
termFreqs: Map<string, number>;
docLength: number;
}
export interface MoveNoteParams {
oldPath: string;
newPath: string;
overwrite?: boolean;
}
export interface MoveFileParams {
oldPath: string;
newPath: string;
confirmOldPath: string;
confirmNewPath: string;
overwrite?: boolean;
}
export interface MoveResult {
success: boolean;
oldPath: string;
newPath: string;
message: string;
}
export interface BatchReadParams {
paths: string[];
includeContent?: boolean;
includeFrontmatter?: boolean;
}
export interface BatchReadResult {
successful: Array<{
path: string;
frontmatter?: Record<string, any>;
content?: string;
obsidianUri?: string;
}>;
failed: Array<{
path: string;
error: string;
}>;
}
export interface UpdateFrontmatterParams {
path: string;
frontmatter: Record<string, any>;
merge?: boolean;
}
export interface NoteInfo {
path: string;
size: number;
modified: number;
hasFrontmatter: boolean;
obsidianUri?: string;
}
export interface TagManagementParams {
path: string;
operation: 'add' | 'remove' | 'list';
tags?: string[];
}
export interface TagManagementResult {
path: string;
operation: string;
tags: string[];
success: boolean;
message?: string;
}
export interface VaultStats {
totalNotes: number;
totalFolders: number;
totalSize: number;
recentlyModified: Array<{
path: string;
modified: number;
}>;
}
//# sourceMappingURL=types.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAClC,IAAI,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;CAC3C;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAGD,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,YAAY,CAAC;IACrB,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAGD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAGD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAClC,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC,CAAC;IACH,MAAM,EAAE,KAAK,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;CACJ;AAGD,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAGD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAGD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAGD,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,KAAK,CAAC;QACtB,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC,CAAC;CACJ"}
+10
View File
@@ -0,0 +1,10 @@
/**
* Generates an Obsidian URI for a given note path.
* Uses the absolute path format: obsidian:///absolute/path/to/note
*
* @param vaultPath - The absolute path to the vault root
* @param notePath - The relative path to the note within the vault
* @returns A properly encoded Obsidian URI
*/
export declare function generateObsidianUri(vaultPath: string, notePath: string): string;
//# sourceMappingURL=uri.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"uri.d.ts","sourceRoot":"","sources":["../../src/uri.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAiB/E"}
-46
View File
@@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest';
import { generateObsidianUri } from './uri.js';
describe('generateObsidianUri', () => {
it('generates URI with absolute path', () => {
const vaultPath = '/Users/test/vault';
const notePath = 'folder/note.md';
const uri = generateObsidianUri(vaultPath, notePath);
expect(uri).toBe('obsidian:////Users/test/vault/folder/note');
});
it('handles paths with leading slash', () => {
const vaultPath = '/Users/test/vault';
const notePath = '/folder/note.md';
const uri = generateObsidianUri(vaultPath, notePath);
expect(uri).toBe('obsidian:////Users/test/vault/folder/note');
});
it('removes .md extension', () => {
const vaultPath = '/Users/test/vault';
const notePath = 'note.md';
const uri = generateObsidianUri(vaultPath, notePath);
expect(uri).toBe('obsidian:////Users/test/vault/note');
});
it('encodes special characters', () => {
const vaultPath = '/Users/test/vault';
const notePath = 'folder/my note with spaces.md';
const uri = generateObsidianUri(vaultPath, notePath);
expect(uri).toBe('obsidian:////Users/test/vault/folder/my%20note%20with%20spaces');
});
it('handles notes in root directory', () => {
const vaultPath = '/Users/test/vault';
const notePath = 'note.md';
const uri = generateObsidianUri(vaultPath, notePath);
expect(uri).toBe('obsidian:////Users/test/vault/note');
});
it('handles nested directories', () => {
const vaultPath = '/Users/test/vault';
const notePath = 'folder1/folder2/folder3/note.md';
const uri = generateObsidianUri(vaultPath, notePath);
expect(uri).toBe('obsidian:////Users/test/vault/folder1/folder2/folder3/note');
});
it('encodes special characters in directory names', () => {
const vaultPath = '/Users/test/vault';
const notePath = 'my folder/sub folder/note.md';
const uri = generateObsidianUri(vaultPath, notePath);
expect(uri).toBe('obsidian:////Users/test/vault/my%20folder/sub%20folder/note');
});
});
+10 -3
View File
@@ -1,13 +1,20 @@
{
"name": "@bitbonsai/mcpvault",
"version": "0.9.1",
"version": "0.10.0",
"description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
"homepage": "https://mcpvault.org",
"author": "bitbonsai",
"license": "MIT",
"type": "module",
"packageManager": "npm@10.9.0+sha512.65a9c38a8172948f617a53619762cd77e12b9950fe1f9239debcb8d62c652f2081824b986fee7c0af6c0a7df615becebe4bf56e17ec27214a87aa29d9e038b4b",
"main": "dist/server.js",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
".": {
"import": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
}
},
"bin": {
"mcpvault": "dist/server.js"
},
@@ -39,7 +46,7 @@
"vitest": "^4.0.15"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"repository": {
"type": "git",
+2 -668
View File
@@ -1,15 +1,7 @@
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { FileSystemService } from "./src/filesystem.js";
import { FrontmatterHandler, parseFrontmatter } from "./src/frontmatter.js";
import { PathFilter } from "./src/pathfilter.js";
import { SearchService } from "./src/search.js";
import { createServer } from "./src/createServer.js";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join, resolve } from "path";
@@ -63,664 +55,6 @@ Examples:
const vaultPathArg = cliArgs.join(' ').trim();
const vaultPath = resolve(vaultPathArg || process.cwd());
// Initialize services
const pathFilter = new PathFilter();
const frontmatterHandler = new FrontmatterHandler();
const fileSystem = new FileSystemService(vaultPath, pathFilter, frontmatterHandler);
const searchService = new SearchService(vaultPath, pathFilter);
const server = new Server({
name: "mcpvault",
version: VERSION
}, {
capabilities: {
tools: {},
},
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "read_note",
description: "Read a note from the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["path"]
}
},
{
name: "write_note",
description: "Write a note to the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
content: {
type: "string",
description: "Content of the note"
},
frontmatter: {
type: "object",
description: "Frontmatter object (optional)"
},
mode: {
type: "string",
enum: ["overwrite", "append", "prepend"],
description: "Write mode: 'overwrite' (default), 'append', or 'prepend'",
default: "overwrite"
}
},
required: ["path", "content"]
}
},
{
name: "patch_note",
description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
oldString: {
type: "string",
description: "The exact string to replace. Must match exactly including whitespace and line breaks."
},
newString: {
type: "string",
description: "The new string to insert in place of oldString"
},
replaceAll: {
type: "boolean",
description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.",
default: false
}
},
required: ["path", "oldString", "newString"]
}
},
{
name: "list_directory",
description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path relative to vault root (default: '/')",
default: "/"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
}
}
},
{
name: "delete_note",
description: "Delete a note from the Obsidian vault (requires confirmation)",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
confirmPath: {
type: "string",
description: "Confirmation: must exactly match the path parameter to proceed with deletion"
}
},
required: ["path", "confirmPath"]
}
},
{
name: "search_notes",
description: "Search for notes in the vault by content or frontmatter",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query text"
},
limit: {
type: "number",
description: "Maximum number of results (default: 5, max: 20)",
default: 5
},
searchContent: {
type: "boolean",
description: "Search in note content (default: true)",
default: true
},
searchFrontmatter: {
type: "boolean",
description: "Search in frontmatter (default: false)",
default: false
},
caseSensitive: {
type: "boolean",
description: "Case sensitive search (default: false)",
default: false
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["query"]
}
},
{
name: "move_note",
description: "Move or rename a note in the vault",
inputSchema: {
type: "object",
properties: {
oldPath: {
type: "string",
description: "Current path of the note"
},
newPath: {
type: "string",
description: "New path for the note"
},
overwrite: {
type: "boolean",
description: "Allow overwriting existing file (default: false)",
default: false
}
},
required: ["oldPath", "newPath"]
}
},
{
name: "move_file",
description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
inputSchema: {
type: "object",
properties: {
oldPath: {
type: "string",
description: "Current path of the file"
},
newPath: {
type: "string",
description: "New path for the file"
},
confirmOldPath: {
type: "string",
description: "Confirmation: must exactly match oldPath"
},
confirmNewPath: {
type: "string",
description: "Confirmation: must exactly match newPath"
},
overwrite: {
type: "boolean",
description: "Allow overwriting existing file (default: false)",
default: false
}
},
required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
}
},
{
name: "read_multiple_notes",
description: "Read multiple notes in a batch (max 10 files)",
inputSchema: {
type: "object",
properties: {
paths: {
type: "array",
items: { type: "string" },
description: "Array of note paths to read",
maxItems: 10
},
includeContent: {
type: "boolean",
description: "Include note content (default: true)",
default: true
},
includeFrontmatter: {
type: "boolean",
description: "Include frontmatter (default: true)",
default: true
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["paths"]
}
},
{
name: "update_frontmatter",
description: "Update frontmatter of a note without changing content",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note"
},
frontmatter: {
type: "object",
description: "Frontmatter object to update"
},
merge: {
type: "boolean",
description: "Merge with existing frontmatter (default: true)",
default: true
}
},
required: ["path", "frontmatter"]
}
},
{
name: "get_notes_info",
description: "Get metadata for notes without reading full content",
inputSchema: {
type: "object",
properties: {
paths: {
type: "array",
items: { type: "string" },
description: "Array of note paths to get info for"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["paths"]
}
},
{
name: "get_frontmatter",
description: "Extract frontmatter from a note without reading the content",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
},
required: ["path"]
}
},
{
name: "manage_tags",
description: "Add, remove, or list tags in a note",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the note relative to vault root"
},
operation: {
type: "string",
enum: ["add", "remove", "list"],
description: "Operation to perform: 'add', 'remove', or 'list'"
},
tags: {
type: "array",
items: { type: "string" },
description: "Array of tags (required for 'add' and 'remove' operations)"
}
},
required: ["path", "operation"]
}
},
{
name: "get_vault_stats",
description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
inputSchema: {
type: "object",
properties: {
recentCount: {
type: "number",
description: "Number of recently modified files to return (default: 5, max: 20)",
default: 5
},
prettyPrint: {
type: "boolean",
description: "Format JSON response with indentation (default: false)",
default: false
}
}
}
}
]
};
});
// Helper function to trim path arguments
function trimPaths(args: any): any {
const trimmed = { ...args };
// Trim single path properties
if (trimmed.path && typeof trimmed.path === 'string') {
trimmed.path = trimmed.path.trim();
}
if (trimmed.oldPath && typeof trimmed.oldPath === 'string') {
trimmed.oldPath = trimmed.oldPath.trim();
}
if (trimmed.newPath && typeof trimmed.newPath === 'string') {
trimmed.newPath = trimmed.newPath.trim();
}
if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string') {
trimmed.confirmPath = trimmed.confirmPath.trim();
}
if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string') {
trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
}
if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string') {
trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
}
// Trim path arrays
if (trimmed.paths && Array.isArray(trimmed.paths)) {
trimmed.paths = trimmed.paths.map((p: any) =>
typeof p === 'string' ? p.trim() : p
);
}
return trimmed;
}
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const trimmedArgs = trimPaths(args);
try {
switch (name) {
case "read_note": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
fm: note.frontmatter,
content: note.content
}, null, indent)
}
]
};
}
case "write_note": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
await fileSystem.writeNote({
path: trimmedArgs.path,
content: trimmedArgs.content,
...(fm !== undefined && { frontmatter: fm }),
mode: trimmedArgs.mode || 'overwrite'
});
return {
content: [
{
type: "text",
text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})`
}
]
};
}
case "patch_note": {
const result = await fileSystem.patchNote({
path: trimmedArgs.path,
oldString: trimmedArgs.oldString,
newString: trimmedArgs.newString,
replaceAll: trimmedArgs.replaceAll
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "list_directory": {
const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
dirs: listing.directories,
files: listing.files
}, null, indent)
}
]
};
}
case "delete_note": {
const result = await fileSystem.deleteNote({
path: trimmedArgs.path,
confirmPath: trimmedArgs.confirmPath
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "search_notes": {
const results = await searchService.search({
query: trimmedArgs.query,
limit: trimmedArgs.limit,
searchContent: trimmedArgs.searchContent,
searchFrontmatter: trimmedArgs.searchFrontmatter,
caseSensitive: trimmedArgs.caseSensitive
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, indent)
}
]
};
}
case "move_note": {
const result = await fileSystem.moveNote({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "move_file": {
const result = await fileSystem.moveFile({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
confirmOldPath: trimmedArgs.confirmOldPath,
confirmNewPath: trimmedArgs.confirmNewPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "read_multiple_notes": {
const result = await fileSystem.readMultipleNotes({
paths: trimmedArgs.paths,
includeContent: trimmedArgs.includeContent,
includeFrontmatter: trimmedArgs.includeFrontmatter
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
ok: result.successful,
err: result.failed
}, null, indent)
}
]
};
}
case "update_frontmatter": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
if (!fm) {
throw new Error('frontmatter is required');
}
await fileSystem.updateFrontmatter({
path: trimmedArgs.path,
frontmatter: fm,
merge: trimmedArgs.merge
});
return {
content: [
{
type: "text",
text: `Successfully updated frontmatter for: ${trimmedArgs.path}`
}
]
};
}
case "get_notes_info": {
const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, indent)
}
]
};
}
case "get_frontmatter": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify(note.frontmatter, null, indent)
}
]
};
}
case "manage_tags": {
const result = await fileSystem.manageTags({
path: trimmedArgs.path,
operation: trimmedArgs.operation,
tags: trimmedArgs.tags
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
isError: !result.success
};
}
case "get_vault_stats": {
const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
const stats = await fileSystem.getVaultStats(recentCount);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
notes: stats.totalNotes,
folders: stats.totalFolders,
size: stats.totalSize,
recent: stats.recentlyModified
}, null, indent)
}
]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
});
const server = createServer(vaultPath, { version: VERSION });
const transport = new StdioServerTransport();
await server.connect(transport);
+94
View File
@@ -0,0 +1,94 @@
import { test, expect, beforeEach, afterEach } from "vitest";
import { createServer } from "./createServer.js";
import { mkdtemp, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
let testVaultPath: string;
beforeEach(async () => {
testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-test-"));
});
afterEach(async () => {
try {
await rm(testVaultPath, { recursive: true });
} catch {
// Ignore cleanup errors
}
});
test("createServer returns a Server instance", () => {
const server = createServer(testVaultPath, { version: "1.0.0" });
expect(server).toBeDefined();
expect(typeof server.connect).toBe("function");
});
test("server registers 14 tools", async () => {
const server = createServer(testVaultPath, { version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "1.0.0" });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const result = await client.listTools();
expect(result.tools).toHaveLength(14);
const toolNames = result.tools.map((t) => t.name).sort();
expect(toolNames).toEqual([
"delete_note",
"get_frontmatter",
"get_notes_info",
"get_vault_stats",
"list_directory",
"manage_tags",
"move_file",
"move_note",
"patch_note",
"read_multiple_notes",
"read_note",
"search_notes",
"update_frontmatter",
"write_note",
]);
await client.close();
await server.close();
});
test("server can read and write notes via tools", async () => {
const server = createServer(testVaultPath, { version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "1.0.0" });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// Write a note
await client.callTool({ name: "write_note", arguments: { path: "test.md", content: "# Hello World" } });
// Read it back
const result = await client.callTool({ name: "read_note", arguments: { path: "test.md" } });
const parsed = JSON.parse((result.content as any)[0].text);
expect(parsed.content).toContain("Hello World");
await client.close();
await server.close();
});
test("custom options are applied", () => {
const server = createServer(testVaultPath, {
name: "custom-name",
version: "2.0.0",
});
expect(server).toBeDefined();
});
+416
View File
@@ -0,0 +1,416 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { FileSystemService } from "./filesystem.js";
import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
import { PathFilter } from "./pathfilter.js";
import { SearchService } from "./search.js";
import { resolve } from "path";
export interface CreateServerOptions {
name?: string;
version?: string;
pathFilter?: PathFilter;
frontmatterHandler?: FrontmatterHandler;
}
export function createServer(vaultPath: string, options: CreateServerOptions = {}): Server {
const {
name = "mcpvault",
version = "0.0.0",
pathFilter = new PathFilter(),
frontmatterHandler = new FrontmatterHandler(),
} = options;
const resolvedVaultPath = resolve(vaultPath);
const fileSystem = new FileSystemService(resolvedVaultPath, pathFilter, frontmatterHandler);
const searchService = new SearchService(resolvedVaultPath, pathFilter);
const server = new Server({ name, version }, {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "read_note",
description: "Read a note from the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["path"]
}
},
{
name: "write_note",
description: "Write a note to the Obsidian vault",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
content: { type: "string", description: "Content of the note" },
frontmatter: { type: "object", description: "Frontmatter object (optional)" },
mode: { type: "string", enum: ["overwrite", "append", "prepend"], description: "Write mode: 'overwrite' (default), 'append', or 'prepend'", default: "overwrite" }
},
required: ["path", "content"]
}
},
{
name: "patch_note",
description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
oldString: { type: "string", description: "The exact string to replace. Must match exactly including whitespace and line breaks." },
newString: { type: "string", description: "The new string to insert in place of oldString" },
replaceAll: { type: "boolean", description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.", default: false }
},
required: ["path", "oldString", "newString"]
}
},
{
name: "list_directory",
description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path relative to vault root (default: '/')", default: "/" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
}
}
},
{
name: "delete_note",
description: "Delete a note from the Obsidian vault (requires confirmation)",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
confirmPath: { type: "string", description: "Confirmation: must exactly match the path parameter to proceed with deletion" }
},
required: ["path", "confirmPath"]
}
},
{
name: "search_notes",
description: "Search for notes in the vault by content or frontmatter",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query text" },
limit: { type: "number", description: "Maximum number of results (default: 5, max: 20)", default: 5 },
searchContent: { type: "boolean", description: "Search in note content (default: true)", default: true },
searchFrontmatter: { type: "boolean", description: "Search in frontmatter (default: false)", default: false },
caseSensitive: { type: "boolean", description: "Case sensitive search (default: false)", default: false },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["query"]
}
},
{
name: "move_note",
description: "Move or rename a note in the vault",
inputSchema: {
type: "object",
properties: {
oldPath: { type: "string", description: "Current path of the note" },
newPath: { type: "string", description: "New path for the note" },
overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
},
required: ["oldPath", "newPath"]
}
},
{
name: "move_file",
description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
inputSchema: {
type: "object",
properties: {
oldPath: { type: "string", description: "Current path of the file" },
newPath: { type: "string", description: "New path for the file" },
confirmOldPath: { type: "string", description: "Confirmation: must exactly match oldPath" },
confirmNewPath: { type: "string", description: "Confirmation: must exactly match newPath" },
overwrite: { type: "boolean", description: "Allow overwriting existing file (default: false)", default: false }
},
required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
}
},
{
name: "read_multiple_notes",
description: "Read multiple notes in a batch (max 10 files)",
inputSchema: {
type: "object",
properties: {
paths: { type: "array", items: { type: "string" }, description: "Array of note paths to read", maxItems: 10 },
includeContent: { type: "boolean", description: "Include note content (default: true)", default: true },
includeFrontmatter: { type: "boolean", description: "Include frontmatter (default: true)", default: true },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["paths"]
}
},
{
name: "update_frontmatter",
description: "Update frontmatter of a note without changing content",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note" },
frontmatter: { type: "object", description: "Frontmatter object to update" },
merge: { type: "boolean", description: "Merge with existing frontmatter (default: true)", default: true }
},
required: ["path", "frontmatter"]
}
},
{
name: "get_notes_info",
description: "Get metadata for notes without reading full content",
inputSchema: {
type: "object",
properties: {
paths: { type: "array", items: { type: "string" }, description: "Array of note paths to get info for" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["paths"]
}
},
{
name: "get_frontmatter",
description: "Extract frontmatter from a note without reading the content",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
},
required: ["path"]
}
},
{
name: "manage_tags",
description: "Add, remove, or list tags in a note",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the note relative to vault root" },
operation: { type: "string", enum: ["add", "remove", "list"], description: "Operation to perform: 'add', 'remove', or 'list'" },
tags: { type: "array", items: { type: "string" }, description: "Array of tags (required for 'add' and 'remove' operations)" }
},
required: ["path", "operation"]
}
},
{
name: "get_vault_stats",
description: "Get vault statistics including total notes, folders, size, and recently modified files. Useful for understanding vault scope before batch operations.",
inputSchema: {
type: "object",
properties: {
recentCount: { type: "number", description: "Number of recently modified files to return (default: 5, max: 20)", default: 5 },
prettyPrint: { type: "boolean", description: "Format JSON response with indentation (default: false)", default: false }
}
}
}
]
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name: toolName, arguments: args } = request.params;
const trimmedArgs = trimPaths(args);
try {
switch (toolName) {
case "read_note": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ fm: note.frontmatter, content: note.content }, null, indent) }]
};
}
case "write_note": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
await fileSystem.writeNote({
path: trimmedArgs.path,
content: trimmedArgs.content,
...(fm !== undefined && { frontmatter: fm }),
mode: trimmedArgs.mode || 'overwrite'
});
return {
content: [{ type: "text", text: `Successfully wrote note: ${trimmedArgs.path} (mode: ${trimmedArgs.mode || 'overwrite'})` }]
};
}
case "patch_note": {
const result = await fileSystem.patchNote({
path: trimmedArgs.path,
oldString: trimmedArgs.oldString,
newString: trimmedArgs.newString,
replaceAll: trimmedArgs.replaceAll
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "list_directory": {
const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ dirs: listing.directories, files: listing.files }, null, indent) }]
};
}
case "delete_note": {
const result = await fileSystem.deleteNote({
path: trimmedArgs.path,
confirmPath: trimmedArgs.confirmPath
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "search_notes": {
const results = await searchService.search({
query: trimmedArgs.query,
limit: trimmedArgs.limit,
searchContent: trimmedArgs.searchContent,
searchFrontmatter: trimmedArgs.searchFrontmatter,
caseSensitive: trimmedArgs.caseSensitive
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify(results, null, indent) }]
};
}
case "move_note": {
const result = await fileSystem.moveNote({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "move_file": {
const result = await fileSystem.moveFile({
oldPath: trimmedArgs.oldPath,
newPath: trimmedArgs.newPath,
confirmOldPath: trimmedArgs.confirmOldPath,
confirmNewPath: trimmedArgs.confirmNewPath,
overwrite: trimmedArgs.overwrite
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "read_multiple_notes": {
const result = await fileSystem.readMultipleNotes({
paths: trimmedArgs.paths,
includeContent: trimmedArgs.includeContent,
includeFrontmatter: trimmedArgs.includeFrontmatter
});
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ ok: result.successful, err: result.failed }, null, indent) }]
};
}
case "update_frontmatter": {
const fm = parseFrontmatter(trimmedArgs.frontmatter);
if (!fm) {
throw new Error('frontmatter is required');
}
await fileSystem.updateFrontmatter({
path: trimmedArgs.path,
frontmatter: fm,
merge: trimmedArgs.merge
});
return {
content: [{ type: "text", text: `Successfully updated frontmatter for: ${trimmedArgs.path}` }]
};
}
case "get_notes_info": {
const result = await fileSystem.getNotesInfo(trimmedArgs.paths);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify(result, null, indent) }]
};
}
case "get_frontmatter": {
const note = await fileSystem.readNote(trimmedArgs.path);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify(note.frontmatter, null, indent) }]
};
}
case "manage_tags": {
const result = await fileSystem.manageTags({
path: trimmedArgs.path,
operation: trimmedArgs.operation,
tags: trimmedArgs.tags
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: !result.success
};
}
case "get_vault_stats": {
const recentCount = Math.min(trimmedArgs.recentCount || 5, 20);
const stats = await fileSystem.getVaultStats(recentCount);
const indent = trimmedArgs.prettyPrint ? 2 : undefined;
return {
content: [{ type: "text", text: JSON.stringify({ notes: stats.totalNotes, folders: stats.totalFolders, size: stats.totalSize, recent: stats.recentlyModified }, null, indent) }]
};
}
default:
throw new Error(`Unknown tool: ${toolName}`);
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
isError: true
};
}
});
return server;
}
function trimPaths(args: any): any {
const trimmed = { ...args };
if (trimmed.path && typeof trimmed.path === 'string') trimmed.path = trimmed.path.trim();
if (trimmed.oldPath && typeof trimmed.oldPath === 'string') trimmed.oldPath = trimmed.oldPath.trim();
if (trimmed.newPath && typeof trimmed.newPath === 'string') trimmed.newPath = trimmed.newPath.trim();
if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string') trimmed.confirmPath = trimmed.confirmPath.trim();
if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string') trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string') trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
if (trimmed.paths && Array.isArray(trimmed.paths)) {
trimmed.paths = trimmed.paths.map((p: any) => typeof p === 'string' ? p.trim() : p);
}
return trimmed;
}
+7
View File
@@ -0,0 +1,7 @@
export { createServer } from './createServer.js';
export type { CreateServerOptions } from './createServer.js';
export { FileSystemService } from './filesystem.js';
export { FrontmatterHandler, parseFrontmatter } from './frontmatter.js';
export { PathFilter } from './pathfilter.js';
export { SearchService } from './search.js';
export * from './types.js';
+4 -2
View File
@@ -2,8 +2,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": false,
"declarationMap": false,
"declaration": true,
"declarationMap": true,
"sourceMap": false
},
"include": [
@@ -11,6 +11,8 @@
"src/**/*"
],
"exclude": [
"src/**/*.test.ts",
"**/*.test.ts",
"tests/**/*",
"dist",
"node_modules"
+2 -1
View File
@@ -15,7 +15,8 @@ This MCP server lets Claude, ChatGPT+, and other assistants access your vault. L
## Recent Updates
- **v0.9.1 (March 2026):** Security fix: symlinks inside the vault that point outside the vault boundary are now blocked. Symlinks resolving within the vault work normally. ([#78](https://github.com/bitbonsai/mcpvault/issues/78))
- **v0.10.0 (March 2026):** New `createServer()` factory for library consumers. MCPVault can now be imported and connected to any MCP transport. TypeScript declarations and all public types exported. ([#84](https://github.com/bitbonsai/mcpvault/issues/84))
- **v0.9.1 (March 2026):** Security fix: symlinks inside the vault that point outside the vault boundary are now blocked. ([#78](https://github.com/bitbonsai/mcpvault/issues/78))
- **v0.9.0 (March 2026):** Package renamed to `@bitbonsai/mcpvault` on npm at Obsidian's request. Update your config: replace `mcpvault` with `@bitbonsai/mcpvault`
- **v0.8.2 (March 2026):** Trailing-slash vault paths no longer truncate search results ([PR #48](https://github.com/bitbonsai/mcpvault/pull/48)), `get_vault_stats` now handles dotted folder names correctly ([PR #42](https://github.com/bitbonsai/mcpvault/pull/42)), note tools now support `.base` and `.canvas` ([PR #53](https://github.com/bitbonsai/mcpvault/pull/53)), string frontmatter inputs are now handled safely ([PR #47](https://github.com/bitbonsai/mcpvault/pull/47)), vault path is now optional in CLI mode (defaults to current working directory, [#50](https://github.com/bitbonsai/mcpvault/issues/50)), and dependency refreshes for the MCP SDK and Node types are merged ([PR #43](https://github.com/bitbonsai/mcpvault/pull/43), [PR #44](https://github.com/bitbonsai/mcpvault/pull/44))
- **v0.8.1:** Multi-word BM25 search relevance improvements ([PR #38](https://github.com/bitbonsai/mcpvault/pull/38)), patch_note undefined/null validation hardening ([PR #37](https://github.com/bitbonsai/mcpvault/pull/37)), new `move_file` tool for binary-safe file moves with explicit path confirmation, binary filenames now visible in directory listings ([#21](https://github.com/bitbonsai/mcpvault/issues/21))
+7 -3
View File
@@ -16,7 +16,7 @@ import { Rocket } from 'lucide-react';
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
Recent Updates
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-accent/20 text-accent border border-accent/30">
v0.9.1
v0.10.0
</span>
</h3>
<button
@@ -44,13 +44,17 @@ import { Rocket } from 'lucide-react';
</div>
<div class="mb-4">
<p class="text-muted-foreground leading-relaxed">
<span class="font-medium text-foreground">v0.9.1 (March 2026):</span> Security fix: symlinks inside the vault that point outside the vault boundary are now blocked. Symlinks resolving within the vault work normally. Also lists symlinked files and directories in <code>list_directory</code> when the target is in-bounds.
(<a href="https://github.com/bitbonsai/mcpvault/issues/78" target="_blank" rel="noopener noreferrer" class="text-accent hover:text-accent-2 transition-colors">#78</a>)
<span class="font-medium text-foreground">v0.10.0 (March 2026):</span> New <code>createServer()</code> factory for library consumers. MCPVault can now be imported as a dependency and connected to any MCP transport (Streamable HTTP, SSE, custom). Also exports TypeScript declarations and all public types.
(<a href="https://github.com/bitbonsai/mcpvault/issues/84" target="_blank" rel="noopener noreferrer" class="text-accent hover:text-accent-2 transition-colors">#84</a>)
</p>
</div>
<div id="older-updates" class="older-updates is-collapsed" data-updates-panel>
<ul class="text-muted-foreground space-y-2">
<li>
<span class="font-medium text-foreground">v0.9.1 (March 2026):</span> Security fix: symlinks inside the vault that point outside the vault boundary are now blocked.
(<a href="https://github.com/bitbonsai/mcpvault/issues/78" target="_blank" rel="noopener noreferrer" class="text-accent hover:text-accent-2 transition-colors">#78</a>)
</li>
<li>
<span class="font-medium text-foreground">v0.9.0 (March 2026):</span> Package renamed to <code>@bitbonsai/mcpvault</code> on npm at Obsidian's request. Update your config: replace <code>mcpvault</code> with <code>@bitbonsai/mcpvault</code>.
</li>