Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/vault-mcp/auth/consent-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")

const scopeList = scopes.length
? scopes.map((s) => `<li>${esc(s)}</li>`).join("")
? scopes.map((scope) => `<li>${escapeHtml(scope)}</li>`).join("")
: "<li><em>No specific scopes requested</em></li>"

return `<!DOCTYPE html>
Expand Down Expand Up @@ -57,21 +57,21 @@ export const renderConsentPage = ({
<body>
<div class="card">
<h1>Authorize access</h1>
${error ? `<div class="error">${esc(error)}</div>` : ""}
${error ? `<div class="error">${escapeHtml(error)}</div>` : ""}
<div class="field">
<div class="label">Application</div>
<div class="value">${esc(clientName)}</div>
<div class="value">${escapeHtml(clientName)}</div>
</div>
<div class="field">
<div class="label">Client ID</div>
<div class="value">${esc(clientId)}</div>
<div class="value">${escapeHtml(clientId)}</div>
</div>
<div class="field">
<div class="label">Requested scopes</div>
<ul>${scopeList}</ul>
</div>
<form method="POST" action="/oauth/decide">
<input type="hidden" name="request_id" value="${esc(requestId)}">
<input type="hidden" name="request_id" value="${escapeHtml(requestId)}">
<div class="field">
<div class="label">Server token</div>
<input type="password" name="token" placeholder="Enter your MCP_AUTH_TOKEN" required autocomplete="off">
Expand Down
20 changes: 10 additions & 10 deletions src/vault-mcp/search/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,35 @@ export const startFileWatcher = (
): Promise<void> => {
const handleChange = async (filePath: string): Promise<void> => {
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),
})
}
}

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,
Expand Down
123 changes: 69 additions & 54 deletions src/vault-mcp/search/search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 ──────────────────────────────────────────────
Expand All @@ -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[] = []

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

Expand Down Expand Up @@ -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 []
}
}
Expand Down Expand Up @@ -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<string, string> = {}
Expand Down Expand Up @@ -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}` : ""
Expand Down
3 changes: 2 additions & 1 deletion src/vault-mcp/vault-operations/memory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
24 changes: 14 additions & 10 deletions src/vault-mcp/vault-operations/vault-patcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 },
}
}

Expand Down