fix: tolerate malformed MCP frontmatter
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}/`
|
||||
|
||||
Reference in New Issue
Block a user