diff --git a/mcp-server/test.js b/mcp-server/test.js index 3dbd7cca..565f1dc8 100644 --- a/mcp-server/test.js +++ b/mcp-server/test.js @@ -4,8 +4,10 @@ import { spawn } from 'node:child_process' import { mkdtemp, mkdir, open, rm, } from 'node:fs/promises' -import path from 'node:path' import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { clearTimeout, setTimeout } from 'node:timers' import { fileURLToPath } from 'node:url' import { findMarkdownFiles, getNote, searchNotes, vaultContext, @@ -42,6 +44,17 @@ is_a: Note # Daily Log Today I worked on the MCP server implementation. +`) + + await writeTextFile(path.join(tmpDir, 'note', 'hashtag-tags.md'), `--- +title: Hashtag Tags +type: Note +tags: [#abc, def, ghi] +--- + +# Hashtag Tags + +This note has AI-generated hashtag-style YAML tags. `) await writeTextFile(path.join(tmpDir, 'project', 'second-project.md'), `--- @@ -65,10 +78,11 @@ after(async () => { describe('findMarkdownFiles', () => { it('should find all .md files recursively', async () => { const files = await findMarkdownFiles(tmpDir) - assert.equal(files.length, 3) + assert.equal(files.length, 4) assert.ok(files.some(f => f.endsWith('test-project.md'))) assert.ok(files.some(f => f.endsWith('daily-log.md'))) assert.ok(files.some(f => f.endsWith('second-project.md'))) + assert.ok(files.some(f => f.endsWith('hashtag-tags.md'))) }) }) @@ -81,6 +95,15 @@ describe('getNote', () => { assert.ok(note.content.includes('test project for the MCP server')) }) + it('should tolerate hashtag-style tags in malformed YAML frontmatter', async () => { + const note = await getNote(tmpDir, 'note/hashtag-tags.md') + assert.equal(note.path, 'note/hashtag-tags.md') + assert.equal(note.frontmatter.title, 'Hashtag Tags') + assert.equal(note.frontmatter.type, 'Note') + assert.deepEqual(note.frontmatter.tags, ['#abc', 'def', 'ghi']) + assert.ok(note.content.includes('has AI-generated hashtag-style YAML tags')) + }) + it('should throw for missing notes', async () => { await assert.rejects( () => getNote(tmpDir, 'nonexistent.md'), @@ -137,6 +160,14 @@ describe('vaultContext', () => { assert.ok(ctx.types.includes('Note')) }) + it('should include notes with hashtag-style tags in malformed YAML frontmatter', async () => { + const ctx = await vaultContext(tmpDir) + const note = ctx.recentNotes.find(entry => entry.path === 'note/hashtag-tags.md') + assert.ok(note) + assert.equal(note.title, 'Hashtag Tags') + assert.equal(note.type, 'Note') + }) + it('should cap recent notes at 20', async () => { const ctx = await vaultContext(tmpDir) assert.ok(ctx.recentNotes.length <= 20) @@ -158,7 +189,7 @@ describe('vaultContext', () => { it('should report correct note count', async () => { const ctx = await vaultContext(tmpDir) - assert.equal(ctx.noteCount, 3) + assert.equal(ctx.noteCount, 4) }) }) diff --git a/mcp-server/vault.js b/mcp-server/vault.js index debfb447..c77b27df 100644 --- a/mcp-server/vault.js +++ b/mcp-server/vault.js @@ -52,7 +52,7 @@ export async function getNote(vaultPath, notePath) { relativePath, } = await resolveVaultNotePath(vaultPath, notePath) const raw = await readUtf8File(noteRealPath) - const parsed = matter(raw) + const parsed = parseMarkdownNote(raw) return { path: relativePath, frontmatter: parsed.data, @@ -109,7 +109,7 @@ export async function vaultContext(vaultPath) { } notesWithMtime.sort((a, b) => b.mtime - a.mtime) - const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest) + const recentNotes = notesWithMtime.slice(0, 20).map(contextNoteWithoutMtime) return { types: [...typesSet].sort(), @@ -160,9 +160,17 @@ function matchesSearchQuery(title, content, query) { return title.toLowerCase().includes(query) || content.toLowerCase().includes(query) } +function contextNoteWithoutMtime(note) { + return { + path: note.path, + title: note.title, + type: note.type, + } +} + async function readVaultContextNote(vaultPath, filePath) { const raw = await readUtf8File(filePath) - const parsed = matter(raw) + const parsed = parseMarkdownNote(raw) const rel = path.relative(vaultPath, filePath) const topFolder = extractTopFolder(rel) const stat = await statFile(filePath) @@ -180,6 +188,129 @@ async function readVaultContextNote(vaultPath, filePath) { } } +function parseMarkdownNote(raw) { + try { + const parsed = matter(raw) + const fallback = parseFrontmatterFallback(raw) + return shouldUseFallbackFrontmatter(parsed, fallback) ? fallback : parsed + } catch { + return parseFrontmatterFallback(raw) + } +} + +function shouldUseFallbackFrontmatter(parsed, fallback) { + return Object.keys(parsed.data).length === 0 && Object.keys(fallback.data).length > 0 +} + +function parseFrontmatterFallback(raw) { + const split = splitFrontmatter(raw) + if (!split) return { data: {}, content: raw } + + return { + data: parseFrontmatterBlock(split.frontmatter), + content: split.content, + } +} + +function splitFrontmatter(raw) { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/) + if (!match) return null + return { frontmatter: match[1], content: match[2] } +} + +function parseFrontmatterBlock(frontmatter) { + const data = {} + let listKey = null + + for (const line of frontmatter.split(/\r?\n/)) { + const item = parseYamlListItem(line) + if (listKey && item !== null) { + data[listKey].push(parseYamlScalar(item)) + continue + } + + listKey = null + const field = parseTopLevelYamlField(line) + if (!field) continue + + data[field.key] = field.value ? parseYamlValue(field.value) : [] + listKey = field.value ? null : field.key + } + + return data +} + +function parseTopLevelYamlField(line) { + if (!line || line.trimStart() !== line || line.trimStart().startsWith('#')) return null + + const separatorIndex = line.indexOf(':') + if (separatorIndex <= 0) return null + + return { + key: stripMatchingQuotes(line.slice(0, separatorIndex).trim()), + value: line.slice(separatorIndex + 1).trim(), + } +} + +function parseYamlValue(value) { + if (value.startsWith('[') && value.endsWith(']')) { + return splitInlineYamlArray(value).map(parseYamlScalar) + } + return parseYamlScalar(value) +} + +function splitInlineYamlArray(value) { + const inner = value.slice(1, -1) + const items = [] + let current = '' + let quote = null + + for (const char of inner) { + if (quote) { + current += char + if (char === quote) quote = null + continue + } + if (char === '"' || char === "'") { + quote = char + current += char + continue + } + if (char === ',') { + items.push(current.trim()) + current = '' + continue + } + current += char + } + + if (current.trim()) items.push(current.trim()) + return items +} + +function parseYamlListItem(line) { + const match = line.match(/^\s+-\s*(.*)$/) + return match ? match[1].trim() : null +} + +function parseYamlScalar(value) { + const unquoted = stripMatchingQuotes(value.trim()) + if (unquoted !== value.trim()) return unquoted + + if (/^(true|yes)$/i.test(unquoted)) return true + if (/^(false|no)$/i.test(unquoted)) return false + if (/^(null|~)$/i.test(unquoted)) return null + if (/^-?\d+(\.\d+)?$/.test(unquoted)) return Number(unquoted) + + return unquoted +} + +function stripMatchingQuotes(value) { + const first = value[0] + const last = value[value.length - 1] + return (first === '"' || first === "'") && first === last ? value.slice(1, -1) : value +} + function extractTopFolder(relativePath) { const topFolder = relativePath.split(path.sep)[0] return topFolder === relativePath ? null : `${topFolder}/`