@bitbonsai/mcpvault 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ import matter from 'gray-matter';
2
+ /**
3
+ * Parse a frontmatter value that may be a JSON string (LLM clients sometimes
4
+ * pass frontmatter as a serialized JSON string instead of an object).
5
+ * Returns undefined if the value is null/undefined, or throws if invalid.
6
+ */
7
+ export function parseFrontmatter(value) {
8
+ if (value === undefined || value === null) {
9
+ return undefined;
10
+ }
11
+ if (typeof value === 'object' && !Array.isArray(value)) {
12
+ return value;
13
+ }
14
+ if (typeof value === 'string') {
15
+ try {
16
+ const parsed = JSON.parse(value);
17
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
18
+ return parsed;
19
+ }
20
+ }
21
+ catch {
22
+ // not valid JSON
23
+ }
24
+ throw new Error('frontmatter must be a JSON object, got a string that is not valid JSON');
25
+ }
26
+ throw new Error(`frontmatter must be a JSON object, got ${typeof value}`);
27
+ }
28
+ export class FrontmatterHandler {
29
+ parse(content) {
30
+ try {
31
+ const parsed = matter(content);
32
+ return {
33
+ frontmatter: parsed.data,
34
+ content: parsed.content,
35
+ originalContent: content
36
+ };
37
+ }
38
+ catch (error) {
39
+ // If parsing fails, treat as content without frontmatter
40
+ return {
41
+ frontmatter: {},
42
+ content: content,
43
+ originalContent: content
44
+ };
45
+ }
46
+ }
47
+ stringify(frontmatterData, content) {
48
+ try {
49
+ // If no frontmatter, return content as-is
50
+ if (!frontmatterData || Object.keys(frontmatterData).length === 0) {
51
+ return content;
52
+ }
53
+ return matter.stringify(content, frontmatterData);
54
+ }
55
+ catch (error) {
56
+ throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
57
+ }
58
+ }
59
+ validate(frontmatterData) {
60
+ const result = {
61
+ isValid: true,
62
+ errors: [],
63
+ warnings: []
64
+ };
65
+ try {
66
+ // Test if the frontmatter can be serialized to valid YAML using gray-matter
67
+ matter.stringify('', frontmatterData);
68
+ }
69
+ catch (error) {
70
+ result.isValid = false;
71
+ result.errors.push(`Invalid YAML structure: ${error instanceof Error ? error.message : 'Unknown error'}`);
72
+ }
73
+ // Check for problematic values
74
+ this.checkForProblematicValues(frontmatterData, result, '');
75
+ return result;
76
+ }
77
+ checkForProblematicValues(obj, result, path) {
78
+ if (obj === null || obj === undefined) {
79
+ return;
80
+ }
81
+ if (typeof obj === 'function') {
82
+ result.errors.push(`Functions are not allowed in frontmatter at path: ${path}`);
83
+ result.isValid = false;
84
+ return;
85
+ }
86
+ if (typeof obj === 'symbol') {
87
+ result.errors.push(`Symbols are not allowed in frontmatter at path: ${path}`);
88
+ result.isValid = false;
89
+ return;
90
+ }
91
+ if (obj instanceof Date) {
92
+ // Dates are fine, but warn if they're invalid
93
+ if (isNaN(obj.getTime())) {
94
+ result.warnings.push(`Invalid date at path: ${path}`);
95
+ }
96
+ return;
97
+ }
98
+ if (Array.isArray(obj)) {
99
+ obj.forEach((item, index) => {
100
+ this.checkForProblematicValues(item, result, `${path}[${index}]`);
101
+ });
102
+ return;
103
+ }
104
+ if (typeof obj === 'object' && obj !== null) {
105
+ for (const [key, value] of Object.entries(obj)) {
106
+ const currentPath = path ? `${path}.${key}` : key;
107
+ // Check for problematic keys
108
+ if (typeof key !== 'string') {
109
+ result.errors.push(`Non-string keys are not allowed: ${key}`);
110
+ result.isValid = false;
111
+ }
112
+ this.checkForProblematicValues(value, result, currentPath);
113
+ }
114
+ }
115
+ }
116
+ extractFrontmatter(content) {
117
+ const parsed = this.parse(content);
118
+ return parsed.frontmatter;
119
+ }
120
+ updateFrontmatter(content, updates) {
121
+ const parsed = this.parse(content);
122
+ const updatedFrontmatter = { ...parsed.frontmatter, ...updates };
123
+ const validation = this.validate(updatedFrontmatter);
124
+ if (!validation.isValid) {
125
+ throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
126
+ }
127
+ return this.stringify(updatedFrontmatter, parsed.content);
128
+ }
129
+ }
@@ -0,0 +1,118 @@
1
+ import { test, expect, describe } from "vitest";
2
+ import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
3
+ const handler = new FrontmatterHandler();
4
+ test("parse note with frontmatter", () => {
5
+ const content = `---
6
+ title: Test Note
7
+ tags: [test, example]
8
+ created: 2023-01-01
9
+ ---
10
+
11
+ # Test Note
12
+
13
+ This is a test note with frontmatter.`;
14
+ const result = handler.parse(content);
15
+ expect(result.frontmatter.title).toBe("Test Note");
16
+ expect(result.frontmatter.tags).toEqual(["test", "example"]);
17
+ expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
18
+ expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
19
+ });
20
+ test("parse note without frontmatter", () => {
21
+ const content = `# Test Note
22
+
23
+ This is a test note without frontmatter.`;
24
+ const result = handler.parse(content);
25
+ expect(result.frontmatter).toEqual({});
26
+ expect(result.content).toBe(content);
27
+ });
28
+ test("stringify with frontmatter", () => {
29
+ const frontmatter = {
30
+ title: "Test Note",
31
+ tags: ["test", "example"]
32
+ };
33
+ const content = "# Test Note\n\nContent here.";
34
+ const result = handler.stringify(frontmatter, content);
35
+ expect(result).toContain("---");
36
+ expect(result).toContain("title: Test Note");
37
+ expect(result).toContain("tags:");
38
+ expect(result).toContain("# Test Note");
39
+ });
40
+ test("stringify without frontmatter", () => {
41
+ const content = "# Test Note\n\nContent here.";
42
+ const result = handler.stringify({}, content);
43
+ expect(result).toBe(content);
44
+ });
45
+ test("validate valid frontmatter", () => {
46
+ const frontmatter = {
47
+ title: "Valid Title",
48
+ tags: ["tag1", "tag2"],
49
+ date: new Date("2023-01-01"),
50
+ count: 42,
51
+ enabled: true
52
+ };
53
+ const result = handler.validate(frontmatter);
54
+ expect(result.isValid).toBe(true);
55
+ expect(result.errors).toHaveLength(0);
56
+ });
57
+ test("validate invalid frontmatter with function", () => {
58
+ const frontmatter = {
59
+ title: "Invalid",
60
+ badFunction: () => "not allowed"
61
+ };
62
+ const result = handler.validate(frontmatter);
63
+ expect(result.isValid).toBe(false);
64
+ expect(result.errors.length).toBeGreaterThan(0);
65
+ // The specific error message may vary between YAML libraries
66
+ expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
67
+ });
68
+ test("update frontmatter in existing content", () => {
69
+ const content = `---
70
+ title: Old Title
71
+ tags: [old]
72
+ ---
73
+
74
+ # Content
75
+
76
+ Some content here.`;
77
+ const updates = {
78
+ title: "New Title",
79
+ modified: "2023-12-01"
80
+ };
81
+ const result = handler.updateFrontmatter(content, updates);
82
+ expect(result).toContain("title: New Title");
83
+ expect(result).toContain("modified: '2023-12-01'");
84
+ expect(result).toContain("tags:");
85
+ expect(result).toContain("# Content");
86
+ });
87
+ describe("parseFrontmatter", () => {
88
+ test("returns undefined for null and undefined", () => {
89
+ expect(parseFrontmatter(null)).toBeUndefined();
90
+ expect(parseFrontmatter(undefined)).toBeUndefined();
91
+ });
92
+ test("passes through a plain object", () => {
93
+ const obj = { tags: ["test"], title: "Hello" };
94
+ expect(parseFrontmatter(obj)).toBe(obj);
95
+ });
96
+ test("parses a JSON string into an object", () => {
97
+ const input = '{"tags": ["test"], "title": "Hello"}';
98
+ expect(parseFrontmatter(input)).toEqual({ tags: ["test"], title: "Hello" });
99
+ });
100
+ test("parses an empty JSON object string", () => {
101
+ expect(parseFrontmatter("{}")).toEqual({});
102
+ });
103
+ test("throws for a non-JSON string", () => {
104
+ expect(() => parseFrontmatter("not json")).toThrow("frontmatter must be a JSON object");
105
+ });
106
+ test("throws for a JSON array string", () => {
107
+ expect(() => parseFrontmatter('[1, 2, 3]')).toThrow("frontmatter must be a JSON object");
108
+ });
109
+ test("throws for a JSON primitive string", () => {
110
+ expect(() => parseFrontmatter('"just a string"')).toThrow("frontmatter must be a JSON object");
111
+ });
112
+ test("throws for an array value", () => {
113
+ expect(() => parseFrontmatter([1, 2, 3])).toThrow("frontmatter must be a JSON object");
114
+ });
115
+ test("throws for a number value", () => {
116
+ expect(() => parseFrontmatter(42)).toThrow("frontmatter must be a JSON object");
117
+ });
118
+ });
@@ -0,0 +1,168 @@
1
+ import { test, expect, beforeEach, afterEach, describe } from "vitest";
2
+ import { FileSystemService } from "./filesystem.js";
3
+ import { FrontmatterHandler } from "./frontmatter.js";
4
+ import { PathFilter } from "./pathfilter.js";
5
+ import { SearchService } from "./search.js";
6
+ import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
7
+ import { join } from "path";
8
+ import { tmpdir } from "os";
9
+ let testVaultPath;
10
+ let pathFilter;
11
+ let frontmatterHandler;
12
+ let fileSystem;
13
+ let searchService;
14
+ beforeEach(async () => {
15
+ testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-integration-"));
16
+ // Initialize services (same as server.ts)
17
+ pathFilter = new PathFilter();
18
+ frontmatterHandler = new FrontmatterHandler();
19
+ fileSystem = new FileSystemService(testVaultPath, pathFilter, frontmatterHandler);
20
+ searchService = new SearchService(testVaultPath, pathFilter);
21
+ });
22
+ afterEach(async () => {
23
+ try {
24
+ await rm(testVaultPath, { recursive: true });
25
+ }
26
+ catch {
27
+ // Ignore cleanup errors
28
+ }
29
+ });
30
+ // ============================================================================
31
+ // INTEGRATION TESTS - END-TO-END WORKFLOW
32
+ // ============================================================================
33
+ describe("Integration: Service Layer Workflows", () => {
34
+ test("write, read, and delete note workflow", async () => {
35
+ // 1. Write a note with frontmatter
36
+ await fileSystem.writeNote({
37
+ path: "test-note.md",
38
+ content: "# Test Note\n\nThis is a test.",
39
+ frontmatter: { tags: ["test"], status: "draft" }
40
+ });
41
+ // 2. Read the note back
42
+ const note = await fileSystem.readNote("test-note.md");
43
+ expect(note.content).toContain("This is a test");
44
+ expect(note.frontmatter?.tags).toEqual(["test"]);
45
+ expect(note.frontmatter?.status).toBe("draft");
46
+ // 3. Delete the note
47
+ const deleteResult = await fileSystem.deleteNote({
48
+ path: "test-note.md",
49
+ confirmPath: "test-note.md"
50
+ });
51
+ expect(deleteResult.success).toBe(true);
52
+ });
53
+ test("search notes with special characters in filenames", async () => {
54
+ // Create notes with special characters in paths
55
+ const testCases = [
56
+ { path: "folder (archive)/note [old].md", content: "# Old Note\n\nArchived keyword." },
57
+ { path: "C++/notes.md", content: "# C++ Notes\n\nProgramming keyword." },
58
+ { path: "backup.2024/important.md", content: "# Important\n\nBackup keyword." },
59
+ { path: "price$100.md", content: "# Pricing\n\nCost keyword." }
60
+ ];
61
+ // Write all test notes
62
+ for (const { path, content } of testCases) {
63
+ if (path.includes('/')) {
64
+ const dirName = path.split('/')[0];
65
+ if (dirName) {
66
+ await mkdir(join(testVaultPath, dirName), { recursive: true });
67
+ }
68
+ }
69
+ await writeFile(join(testVaultPath, path), content);
70
+ }
71
+ // Search for keyword
72
+ const results = await searchService.search({
73
+ query: "keyword",
74
+ limit: 10
75
+ });
76
+ expect(results.length).toBe(4);
77
+ // Verify paths with special characters are returned correctly
78
+ const paths = results.map((r) => r.p);
79
+ expect(paths).toContain("folder (archive)/note [old].md");
80
+ expect(paths).toContain("C++/notes.md");
81
+ });
82
+ test("write note with regex special chars in content", async () => {
83
+ const content = `# Price List
84
+
85
+ Item: Widget ($10.50)
86
+ Regex: [a-z]+ matches lowercase
87
+ Math: 2 + 2 = 4
88
+ Pattern: backup.2024/**/*.md`;
89
+ await fileSystem.writeNote({
90
+ path: "special-chars.md",
91
+ content
92
+ });
93
+ // Read back and verify exact content
94
+ const note = await fileSystem.readNote("special-chars.md");
95
+ expect(note.content).toContain("($10.50)");
96
+ expect(note.content).toContain("[a-z]+");
97
+ expect(note.content).toContain("2 + 2 = 4");
98
+ expect(note.content).toContain("backup.2024/**/*.md");
99
+ });
100
+ test("search matches note filename even without content match", async () => {
101
+ // Issue #30: notes without a heading that rely on filename for discovery
102
+ await fileSystem.writeNote({
103
+ path: "Yard.md",
104
+ content: "Some info about lawn care and gardening tips."
105
+ });
106
+ await fileSystem.writeNote({
107
+ path: "Kitchen.md",
108
+ content: "Recipes and kitchen organization."
109
+ });
110
+ // Search for "yard" — should match Yard.md by filename
111
+ const results = await searchService.search({
112
+ query: "yard",
113
+ searchContent: true,
114
+ limit: 10
115
+ });
116
+ expect(results.length).toBeGreaterThanOrEqual(1);
117
+ expect(results.some(r => r.p === "Yard.md")).toBe(true);
118
+ // Verify filename-only match has reasonable fields
119
+ const yardResult = results.find(r => r.p === "Yard.md");
120
+ expect(yardResult.t).toBe("Yard");
121
+ expect(yardResult.mc).toBeGreaterThanOrEqual(1);
122
+ // Search for "kitchen" — should match Kitchen.md by filename
123
+ const kitchenResults = await searchService.search({
124
+ query: "kitchen",
125
+ searchContent: true,
126
+ limit: 10
127
+ });
128
+ // Should match both filename AND content (content contains "kitchen")
129
+ expect(kitchenResults.some(r => r.p === "Kitchen.md")).toBe(true);
130
+ });
131
+ test("multi-step workflow: search, read multiple, update frontmatter", async () => {
132
+ // Create several notes
133
+ for (let i = 1; i <= 3; i++) {
134
+ await fileSystem.writeNote({
135
+ path: `note-${i}.md`,
136
+ content: `# Note ${i}\n\nThis contains searchterm.`,
137
+ frontmatter: { id: i, processed: false }
138
+ });
139
+ }
140
+ // Search for notes
141
+ const searchResults = await searchService.search({
142
+ query: "searchterm",
143
+ limit: 10
144
+ });
145
+ expect(searchResults.length).toBe(3);
146
+ // Read multiple notes
147
+ const paths = searchResults.map(r => r.p);
148
+ const readResult = await fileSystem.readMultipleNotes({
149
+ paths,
150
+ includeContent: true,
151
+ includeFrontmatter: true
152
+ });
153
+ expect(readResult.successful.length).toBe(3);
154
+ // Update frontmatter on all notes
155
+ for (const path of paths) {
156
+ await fileSystem.updateFrontmatter({
157
+ path,
158
+ frontmatter: { processed: true },
159
+ merge: true
160
+ });
161
+ }
162
+ // Verify updates
163
+ for (const path of paths) {
164
+ const note = await fileSystem.readNote(path);
165
+ expect(note.frontmatter?.processed).toBe(true);
166
+ }
167
+ });
168
+ });
@@ -0,0 +1,94 @@
1
+ export class PathFilter {
2
+ ignoredPatterns;
3
+ allowedExtensions;
4
+ constructor(config) {
5
+ this.ignoredPatterns = [
6
+ '.obsidian',
7
+ '.obsidian/**',
8
+ '.git',
9
+ '.git/**',
10
+ 'node_modules',
11
+ 'node_modules/**',
12
+ '.DS_Store',
13
+ 'Thumbs.db',
14
+ ...config?.ignoredPatterns || []
15
+ ];
16
+ this.allowedExtensions = [
17
+ '.md',
18
+ '.markdown',
19
+ '.txt',
20
+ '.base', // Obsidian Bases (YAML)
21
+ '.canvas', // Obsidian Canvas (JSON)
22
+ ...config?.allowedExtensions || []
23
+ ];
24
+ }
25
+ simpleGlobMatch(pattern, path) {
26
+ // Normalize pattern path separators (Windows compatibility)
27
+ const normalizedPattern = pattern.replace(/\\/g, '/');
28
+ // Convert glob pattern to regex, escaping special regex chars first
29
+ let regexPattern = normalizedPattern
30
+ .replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') // Escape all regex special chars
31
+ .replace(/\\\*\\\*/g, '.*') // ** matches any number of directories (unescape)
32
+ .replace(/\\\*/g, '[^/]*') // * matches anything except / (unescape)
33
+ .replace(/\\\?/g, '[^/]'); // ? matches single character except / (unescape)
34
+ // Ensure we match the full path
35
+ regexPattern = '^' + regexPattern + '$';
36
+ const regex = new RegExp(regexPattern);
37
+ return regex.test(path);
38
+ }
39
+ isAllowed(path) {
40
+ // Normalize path separators
41
+ const normalizedPath = path.replace(/\\/g, '/');
42
+ if (this.isIgnoredPath(normalizedPath)) {
43
+ return false;
44
+ }
45
+ // For files, check extension if allowedExtensions is configured
46
+ if (this.allowedExtensions.length > 0 && this.isFile(normalizedPath)) {
47
+ const hasAllowedExtension = this.allowedExtensions.some(ext => normalizedPath.toLowerCase().endsWith(ext.toLowerCase()));
48
+ if (!hasAllowedExtension) {
49
+ return false;
50
+ }
51
+ }
52
+ return true;
53
+ }
54
+ isAllowedForListing(path) {
55
+ // Normalize path separators
56
+ const normalizedPath = path.replace(/\\/g, '/');
57
+ // Listing includes non-note files, but still blocks restricted system paths
58
+ return !this.isIgnoredPath(normalizedPath);
59
+ }
60
+ isIgnoredPath(normalizedPath) {
61
+ // Check if path matches any ignored pattern
62
+ for (const pattern of this.ignoredPatterns) {
63
+ if (this.simpleGlobMatch(pattern, normalizedPath)) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+ isFile(path) {
70
+ // A path is a file if it has a file extension at the end
71
+ // Paths ending with '/' are always directories
72
+ if (path.endsWith('/')) {
73
+ return false;
74
+ }
75
+ // Get the last component of the path
76
+ const lastSlashIndex = path.lastIndexOf('/');
77
+ const lastComponent = lastSlashIndex === -1 ? path : path.substring(lastSlashIndex + 1);
78
+ // Check if the last component has a file extension
79
+ // A file extension is a dot followed by 1-10 alphanumeric characters at the end
80
+ // This distinguishes "file.md" (file) from "1. Project" (directory with dot in name)
81
+ const lastDotIndex = lastComponent.lastIndexOf('.');
82
+ if (lastDotIndex === -1 || lastDotIndex === 0) {
83
+ // No dot, or dot at the start (like .gitignore) - treat as no extension
84
+ return false;
85
+ }
86
+ const extension = lastComponent.substring(lastDotIndex + 1);
87
+ // Extension should be 1-10 characters and contain only alphanumeric characters
88
+ // This allows .md, .txt, .markdown but not ". Project" (space after dot)
89
+ return extension.length >= 1 && extension.length <= 10 && /^[a-zA-Z0-9]+$/.test(extension);
90
+ }
91
+ filterPaths(paths) {
92
+ return paths.filter(path => this.isAllowed(path));
93
+ }
94
+ }