fix: tolerate malformed MCP frontmatter

This commit is contained in:
lucaronin
2026-05-11 14:07:23 +02:00
parent 0715f51b02
commit 5a14848cf8
2 changed files with 168 additions and 6 deletions

View File

@@ -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)
})
})

View File

@@ -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}/`