mirror of
https://github.com/bitbonsai/mcpvault.git
synced 2026-09-19 07:37:47 +08:00
feat: extract createServer factory for library consumers (closes #84)
This commit is contained in:
@@ -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
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
//# sourceMappingURL=server.d.ts.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../server.ts"],"names":[],"mappings":""}
|
||||
+9
-648
@@ -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);
|
||||
|
||||
Vendored
+11
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+383
@@ -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;
|
||||
}
|
||||
Vendored
+26
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+73
-5
@@ -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(),
|
||||
|
||||
Vendored
-946
@@ -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/);
|
||||
});
|
||||
Vendored
+16
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
-118
@@ -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");
|
||||
});
|
||||
});
|
||||
Vendored
+8
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+6
@@ -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';
|
||||
Vendored
-168
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+13
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
-264
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+11
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
-212
@@ -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");
|
||||
});
|
||||
});
|
||||
Vendored
+134
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+10
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
-46
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user