diff --git a/src/vault-mcp/auth/consent-page.ts b/src/vault-mcp/auth/consent-page.ts index 61298e1..cde5f96 100644 --- a/src/vault-mcp/auth/consent-page.ts +++ b/src/vault-mcp/auth/consent-page.ts @@ -15,15 +15,15 @@ export const renderConsentPage = ({ requestId, error, }: ConsentPageParams): string => { - const esc = (s: string): string => - s + const escapeHtml = (text: string): string => + text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) const scopeList = scopes.length - ? scopes.map((s) => `
  • ${esc(s)}
  • `).join("") + ? scopes.map((scope) => `
  • ${escapeHtml(scope)}
  • `).join("") : "
  • No specific scopes requested
  • " return ` @@ -57,21 +57,21 @@ export const renderConsentPage = ({

    Authorize access

    - ${error ? `
    ${esc(error)}
    ` : ""} + ${error ? `
    ${escapeHtml(error)}
    ` : ""}
    Application
    -
    ${esc(clientName)}
    +
    ${escapeHtml(clientName)}
    Client ID
    -
    ${esc(clientId)}
    +
    ${escapeHtml(clientId)}
    Requested scopes
    - +
    Server token
    diff --git a/src/vault-mcp/search/file-watcher.ts b/src/vault-mcp/search/file-watcher.ts index 1481165..a700b0b 100644 --- a/src/vault-mcp/search/file-watcher.ts +++ b/src/vault-mcp/search/file-watcher.ts @@ -21,17 +21,17 @@ export const startFileWatcher = ( ): Promise => { const handleChange = async (filePath: string): Promise => { if (!filePath.endsWith(".md")) return - const relPath = relative(vaultPath, filePath) + const relativePath = relative(vaultPath, filePath) try { const [content, fileStat] = await Promise.all([ readFile(filePath, "utf8"), stat(filePath), ]) - search.upsertNote(relPath, content, fileStat.mtimeMs) - logger.debug("indexed", { path: relPath }) + search.upsertNote(relativePath, content, fileStat.mtimeMs) + logger.debug("indexed", { path: relativePath }) } catch (err) { logger.error("failed to index file", { - path: relPath, + path: relativePath, error: err instanceof Error ? err.message : String(err), }) } @@ -39,17 +39,17 @@ export const startFileWatcher = ( const handleDelete = (filePath: string): void => { if (!filePath.endsWith(".md")) return - const relPath = relative(vaultPath, filePath) - search.removeNote(relPath) - logger.debug("removed from index", { path: relPath }) + const relativePath = relative(vaultPath, filePath) + search.removeNote(relativePath) + logger.debug("removed from index", { path: relativePath }) } const watcher = watch(vaultPath, { // Skip dotfiles/directories (.obsidian/, .trash/) but allow the vault root itself ignored: (path: string) => { - const rel = relative(vaultPath, path) - if (!rel) return false - return rel.split("/").some((seg) => seg.startsWith(".")) + const relativePath = relative(vaultPath, path) + if (!relativePath) return false + return relativePath.split("/").some((segment) => segment.startsWith(".")) }, persistent: true, ignoreInitial: true, diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index c8869db..e282895 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -26,6 +26,12 @@ const convertFrontmatterDatesToIsoStrings = ( ]), ) +/** Coerces a YAML frontmatter field to a string array. + * gray-matter may parse multi-value YAML fields as a single string + * or an array depending on syntax (flow vs block). */ +const coerceToArray = (value: unknown): string[] => + Array.isArray(value) ? value : value ? [String(value)] : [] + // ── FTS5 query sanitization ───────────────────────────────────── const FTS5_RESERVED = new Set(["AND", "OR", "NOT", "NEAR"]) @@ -334,35 +340,32 @@ export const createSearchIndex = (dbPath: string) => { ): void => { const skipLinks = options?.skipLinks ?? false const parsed = matter(rawContent) - const { data } = parsed - - // gray-matter may parse tags/related as a single string or array depending on YAML syntax - const tags = Array.isArray(data.tags) - ? data.tags - : data.tags - ? [data.tags] - : [] - const related = Array.isArray(data.related) - ? data.related - : data.related - ? [data.related] - : [] + const { data: frontmatter } = parsed + + const tags = coerceToArray(frontmatter.tags) + const related = coerceToArray(frontmatter.related) + // gray-matter auto-parses YAML dates to JS Date objects (YAML 1.1); + // handle both Date and string forms to normalize to ISO const note = { path: filePath, - title: isString(data.title) ? data.title : basename(filePath, ".md"), + title: isString(frontmatter.title) + ? frontmatter.title + : basename(filePath, ".md"), content: parsed.content, tags: JSON.stringify(tags), related: JSON.stringify(related), folder: filePath.includes("/") ? filePath.split("/")[0] : "", - type: isString(data.type) ? data.type : null, - created: isDate(data.created) - ? DateTime.fromJSDate(data.created).toISO() - : isString(data.created) - ? DateTime.fromISO(data.created).toISO() + type: isString(frontmatter.type) ? frontmatter.type : null, + created: isDate(frontmatter.created) + ? DateTime.fromJSDate(frontmatter.created).toISO() + : isString(frontmatter.created) + ? DateTime.fromISO(frontmatter.created).toISO() : null, mtime: lastModifiedMs, - properties: JSON.stringify(convertFrontmatterDatesToIsoStrings(data)), + properties: JSON.stringify( + convertFrontmatterDatesToIsoStrings(frontmatter), + ), } deleteFtsStmt.run(note.path) @@ -420,35 +423,41 @@ export const createSearchIndex = (dbPath: string) => { withFileTypes: true, }) - const files = entries.reduce<{ rel: string; full: string }[]>( - (acc, entry) => { - if (!entry.isFile() || !entry.name.endsWith(".md")) return acc - const full = join(entry.parentPath, entry.name) - const rel = relative(normalizedVault, full) - if (rel.split("/").some((seg) => seg.startsWith("."))) return acc - acc.push({ rel, full }) - return acc - }, - [], - ) - - const items = await Promise.all( - files.map(async (file) => { + // Filter directory entries to visible .md files, then load their content + const markdownFiles = entries.reduce< + { relativePath: string; absolutePath: string }[] + >((filteredFiles, directoryEntry) => { + if (!directoryEntry.isFile() || !directoryEntry.name.endsWith(".md")) + return filteredFiles + const absolutePath = join(directoryEntry.parentPath, directoryEntry.name) + const relativePath = relative(normalizedVault, absolutePath) + if (relativePath.split("/").some((segment) => segment.startsWith("."))) + return filteredFiles + return [...filteredFiles, { relativePath, absolutePath }] + }, []) + + const noteContents = await Promise.all( + markdownFiles.map(async (file) => { const [content, fileStat] = await Promise.all([ - readFile(file.full, "utf8"), - stat(file.full), + readFile(file.absolutePath, "utf8"), + stat(file.absolutePath), ]) - return { rel: file.rel, content, mtime: fileStat.mtimeMs } + return { + relativePath: file.relativePath, + content, + modifiedAtMs: fileStat.mtimeMs, + } }), ) + // better-sqlite3: .transaction() returns a function; call it immediately db.transaction(() => { // Pass 1: index all notes (content, frontmatter, FTS) — skip link // extraction here; Pass 2 handles it with the complete path list. - for (const item of items) { - // Skip link extraction here — Pass 2 handles it with the complete - // path list so all forward references can resolve correctly. - upsertNote(item.rel, item.content, item.mtime, { skipLinks: true }) + for (const note of noteContents) { + upsertNote(note.relativePath, note.content, note.modifiedAtMs, { + skipLinks: true, + }) } // Pass 2: re-extract links now that all paths are in the notes table, @@ -460,17 +469,17 @@ export const createSearchIndex = (dbPath: string) => { const pathList = allPaths.map((row) => row.path) db.exec("DELETE FROM links") - for (const item of items) { - const parsed = matter(item.content) + for (const note of noteContents) { + const parsed = matter(note.content) for (const rawTarget of extractLinks(parsed.content)) { const resolved = resolveLink(rawTarget, pathList) - insertLinkStmt.run(item.rel, resolved ?? rawTarget) + insertLinkStmt.run(note.relativePath, resolved ?? rawTarget) } } })() - logger.info("rebuilt index", { count: items.length }) - return items.length + logger.info("rebuilt index", { count: noteContents.length }) + return noteContents.length } // ── Query methods ────────────────────────────────────────────── @@ -480,6 +489,7 @@ export const createSearchIndex = (dbPath: string) => { params: { query: string; filters?: SearchFilters }, logger: Logger, ): SearchResult[] => { + // Build WHERE clause dynamically: each filter appends a condition + its bind params const conditions: string[] = [] const queryParams: unknown[] = [] @@ -501,11 +511,11 @@ export const createSearchIndex = (dbPath: string) => { } if (params.filters?.related) { - for (const rel of params.filters.related) { + for (const relatedNote of params.filters.related) { conditions.push( "EXISTS (SELECT 1 FROM json_each(n.related) WHERE value = ?)", ) - queryParams.push(rel) + queryParams.push(relatedNote) } } @@ -564,7 +574,11 @@ export const createSearchIndex = (dbPath: string) => { resultCount: results.length, }) return results - } catch { + } catch (error) { + logger.warn("full text search failed", { + query: params.query, + error: error instanceof Error ? error.message : String(error), + }) return [] } } @@ -682,10 +696,10 @@ export const createSearchIndex = (dbPath: string) => { : "" const keySql = ` - SELECT je.key, COUNT(DISTINCT n.path) as count - FROM notes n, json_each(n.properties) je + SELECT property.key, COUNT(DISTINCT n.path) as count + FROM notes n, json_each(n.properties) property ${folderCondition} - GROUP BY je.key + GROUP BY property.key ORDER BY count DESC ` const keySqlParams: Record = {} @@ -897,8 +911,9 @@ export const createSearchIndex = (dbPath: string) => { const excludeFolders = params.excludeFolders ?? [] const limit = params.limit ?? 50 - const folderExclusions = excludeFolders - .map(() => "path NOT LIKE ? || '/%'") + // One exclusion clause per folder, each bound to a positional parameter + const folderExclusions = Array(excludeFolders.length) + .fill("path NOT LIKE ? || '/%'") .join(" AND ") const whereClause = excludeFolders.length > 0 ? `AND ${folderExclusions}` : "" diff --git a/src/vault-mcp/vault-operations/memory-store.ts b/src/vault-mcp/vault-operations/memory-store.ts index dff123d..e371e12 100644 --- a/src/vault-mcp/vault-operations/memory-store.ts +++ b/src/vault-mcp/vault-operations/memory-store.ts @@ -382,7 +382,8 @@ export const createMemoryStore = (options: { memoryDir: string }) => { ENTRY_PATTERN.test(line), ) const lastBulletOffset = bodyLines.reduce( - (last, line, index) => (ENTRY_PATTERN.test(line) ? index : last), + (lastMatchIndex, line, index) => + ENTRY_PATTERN.test(line) ? index : lastMatchIndex, -1, ) diff --git a/src/vault-mcp/vault-operations/vault-patcher.ts b/src/vault-mcp/vault-operations/vault-patcher.ts index f174b56..1331cf1 100644 --- a/src/vault-mcp/vault-operations/vault-patcher.ts +++ b/src/vault-mcp/vault-operations/vault-patcher.ts @@ -53,17 +53,19 @@ const findTrailingCommentBlockStart = (lines: readonly string[]): number => { // Inside a fenced code block (outside comments): only a matching close // fence matters — `%%` is code and ignored. if (fence && !comment) { + const fenceChars = fenceMatch?.[1] const isFenceClose = - fenceMatch && - fenceMatch[1][0] === fence.char && - fenceMatch[1].length >= fence.length && - line.trim() === fenceMatch[1] + fenceChars && + fenceChars[0] === fence.char && + fenceChars.length >= fence.length && + line.trim() === fenceChars if (isFenceClose) fence = null continue } if (fenceMatch && !comment) { - fence = { char: fenceMatch[1][0], length: fenceMatch[1].length } + const fenceChars = fenceMatch[1] + fence = { char: fenceChars[0], length: fenceChars.length } continue } @@ -131,19 +133,21 @@ const parseHeadings = (lines: readonly string[]): HeadingInfo[] => { if (state.fence) { // Inside a fenced block — only exit when we see a closing fence of the // same character with length >= the opener, and nothing else on the line + const fenceChars = fenceMatch?.[1] const isFenceClose = - fenceMatch && - fenceMatch[1][0] === state.fence.char && - fenceMatch[1].length >= state.fence.length && - line.trim() === fenceMatch[1] + fenceChars && + fenceChars[0] === state.fence.char && + fenceChars.length >= state.fence.length && + line.trim() === fenceChars return isFenceClose ? { headings: state.headings, fence: null } : state } // Opening a new fenced code block if (fenceMatch) { + const fenceChars = fenceMatch[1] return { headings: state.headings, - fence: { char: fenceMatch[1][0], length: fenceMatch[1].length }, + fence: { char: fenceChars[0], length: fenceChars.length }, } }