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
25 changes: 20 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,21 @@ The edit tools differ in how they locate the lines they change — by heading, b

The three anchor tools share one resolution rule: a short, case-sensitive substring locates a full line, ambiguity is an error, and `first_match` takes the first match instead.

`vault_delete_note` refuses paths under protected folders as a server-side guardrail. The default protected set is the memory dir plus the daily notes folder, read at operation time from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`). `PROTECTED_PATHS` overrides the default entirely. Use `vault_delete_memory` for individual entries in memory files. `vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`.
`vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`.

`vault_delete_note` and `vault_move_note` refuse paths under protected folders as a server-side guardrail:

- **Default set:** the memory dir plus the daily notes folder, resolved on every delete and move as [Daily notes](#property-discovery--daily-notes) describes
- **Unreadable `daily-notes.json`:** a file that exists but cannot be read or parsed refuses the delete or move, because the folder to protect is unknown; setting `DAILY_NOTES_FOLDER` skips the file
- **Override:** `PROTECTED_PATHS` replaces the default set entirely, and `daily-notes.json` is not read

To remove a single entry from a memory file, use `vault_delete_memory`.

`vault_move_note` moves or renames a note and rewrites every link across the vault that resolves to it, mirroring Obsidian's built-in rename:

- **Every link form:** wikilinks (including aliases, heading anchors, and embeds), markdown links, and frontmatter links, resolved with the same logic as the link-graph tools
- **Minimal rewrites:** a link is rewritten only when leaving it unchanged would break it
- **Guardrails:** refuses to overwrite an existing destination, and blocks moves out of or into `PROTECTED_PATHS`
- **Guardrails:** refuses to overwrite an existing destination, and blocks moves out of or into protected folders

Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to clean up parent directories left empty by the operation.

Expand Down Expand Up @@ -209,7 +217,11 @@ Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to

**Promoted properties:** Five frontmatter keys — `title`, `tags`, `type`, `created`, `related` — get dedicated columns in the `notes` table for direct `WHERE`-clause filtering (no `json_extract` needed). In tool responses, these appear as top-level fields; remaining frontmatter keys are returned under `additional_properties` (via `formatNoteMetadata` in `tool-helpers.ts`). All other properties live in a JSON `properties` column — functional for any schema, but without dedicated columns. The property queries match a key as data through `json_each` over that column, never as a JSON path, so a property named `a.b` or `k[0]` is matched like any other. Array values are unpacked via `json_each`, so scalar and list properties both match.

**Daily notes:** `vault_get_daily_note` resolves the vault's folder and date format, each independently: `DAILY_NOTES_FOLDER`/`DAILY_NOTES_FORMAT` env setting → `.obsidian/daily-notes.json` → fallback (`Daily Notes/YYYY-MM-DD.md`). Only a successful config-file read is cached — a missing or malformed file is re-read on the next call, so a config file that arrives after boot is picked up without a restart. `task-format-config.ts` uses the same cache rule.
**Daily notes:** `vault_get_daily_note` resolves the vault's folder and date format, each independently: `DAILY_NOTES_FOLDER`/`DAILY_NOTES_FORMAT` env setting → `.obsidian/daily-notes.json` → fallback (`Daily Notes/YYYY-MM-DD.md`).

- **Read on every call:** a folder or format change in Obsidian applies to the next call, and a config file that arrives after boot needs no restart
- **Missing file:** takes the fallbacks
- **Unparseable file:** takes the fallbacks and logs a `warn`

### Memory

Expand Down Expand Up @@ -353,6 +365,8 @@ Four design choices shape the query surface:
- **Heading moves and position** — `heading` moves the task and its indented sub-items to another section; on a Kanban board that is a lane move, but any note with headings works. `position` (`"top"`, `"bottom"`, or a 1-based integer) selects where within the target heading the card lands; without a `heading`, it triggers a same-lane reorder (rejected when the task sits above the first heading or the lane's heading name is duplicated). A sub-task (depth > 0) never moves or reorders: an explicit `heading` or `position` is rejected, and `status: "done"` changes its checkbox in place.
- **`add_subtasks`** — appends checklist items under the task's existing ones.

`vault_create_task` and `vault_update_task` read the Tasks plugin's format and date toggles on every write, so a change in Obsidian applies to the next write. The plugin's status registry is read once at boot, so `vault_list_tasks` and the two write tools always agree on which checkboxes are tasks; a status-type change needs a restart.

## MCP Prompts

Alongside tools, the server registers MCP **prompts** (`prompts/list` / `prompts/get`) — user-initiated workflows, distinct from the model-driven tools:
Expand Down Expand Up @@ -1205,8 +1219,9 @@ Docker hardening, and durability seatbelts above.
like `../../outside` cannot escape the memory directory — and leading
dots, which would create hidden files (memory paths are built via
`join`, bypassing `resolveSafePath`'s hidden-path guard).
- **Protected paths**: `PROTECTED_PATHS` (default: `MEMORY_DIR` plus
`DAILY_NOTES_FOLDER`, falling back to `Daily Notes`) blocks deleting
- **Protected paths**: `PROTECTED_PATHS` (default: `MEMORY_DIR` plus the
daily notes folder, from `DAILY_NOTES_FOLDER`, then
`.obsidian/daily-notes.json`, then `Daily Notes`) blocks deleting
notes in, moving notes out of, and moving notes into configured
folders. The check (`isProtectedPath()`, shared by delete and move)
runs on the canonical vault-relative path with a case-folded
Expand Down
112 changes: 112 additions & 0 deletions src/__tests__/integration/server-error-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1479,3 +1479,115 @@ describe("cannot read trash config", () => {
})
}, 30_000)
})

describe("cannot read daily notes config", () => {
it("an unreadable daily-notes.json refuses delete and move until it is repaired", async () => {
const server = await startServer(await freePort())
onTestFinished(() => server.cleanup())
const dailyNotesConfigPath = join(server.vaultPath, ".obsidian", "daily-notes.json")
await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Journal" }), "utf8")
const ownClient = await createTestClient(server.port)
onTestFinished(() => ownClient.close())
for (const path of ["Journal/entry.md", "Scratch/keep.md"]) {
await callTool({ client: ownClient, name: "vault_write_note", args: { path, body: "kept" } })
}

// A refused delete while the file is valid: the folder has been read once.
const refusedWhileValid = await callTool({
client: ownClient,
name: "vault_delete_note",
args: { path: "Journal/entry.md" },
})
expect(textContent(refusedWhileValid)).toBe(
'[Error]: cannot delete protected path "Journal/entry.md"',
)

await writeFile(dailyNotesConfigPath, "", "utf8")

const deleteWhileUnreadable = await callTool({
client: ownClient,
name: "vault_delete_note",
args: { path: "Scratch/keep.md" },
})
const moveWhileUnreadable = await callTool({
client: ownClient,
name: "vault_move_note",
args: { old_path: "Scratch/keep.md", new_path: "Scratch/moved.md" },
})

// Asserting the whole result keeps the cause out of every part of it.
expect(deleteWhileUnreadable).toEqual({
content: [
{
type: "text",
text: "[Error]: cannot read daily notes config from .obsidian/daily-notes.json",
},
],
isError: true,
})
expect(moveWhileUnreadable).toEqual({
content: [
{
type: "text",
text: "[Error]: cannot read daily notes config from .obsidian/daily-notes.json",
},
],
isError: true,
})
await expect(readFile(join(server.vaultPath, "Scratch", "keep.md"), "utf8")).resolves.toBe(
"kept\n",
)
await expect(readFile(join(server.vaultPath, "Journal", "entry.md"), "utf8")).resolves.toBe(
"kept\n",
)
await expect(readFile(join(server.vaultPath, "Scratch", "moved.md"), "utf8")).rejects.toThrow(
"ENOENT",
)

await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Journal" }), "utf8")

const deleteAfterRepair = await callTool({
client: ownClient,
name: "vault_delete_note",
args: { path: "Scratch/keep.md" },
})

expect(deleteAfterRepair.isError).not.toBe(true)
expect(textContent(deleteAfterRepair)).toBe(
"Moved Scratch/keep.md to trash (.trash/Scratch/keep.md)",
)

// The warning carries the delete's request context, as the trash reader's does.
const { deleteCallLog, readFailureLog } = await vi.waitFor(() => {
const logEntries = parseLogEntries(server.stdout())
const loggedReadFailure = logEntries.find(
(logEntry) => logEntry.message === "cannot read daily notes config",
)

if (!loggedReadFailure) {
throw new Error("the read failure is not logged yet")
}

return {
deleteCallLog: logEntries.find(
(logEntry) =>
logEntry.message === "tool_call" &&
logEntry.tool === "vault_delete_note" &&
logEntry.path === "Scratch/keep.md",
),
readFailureLog: loggedReadFailure,
}
})

expect(typeof deleteCallLog?.requestId).toBe("number")
expect({
error: readFailureLog.error,
tool: readFailureLog.tool,
requestId: readFailureLog.requestId,
}).toEqual({
error: `[SyntaxError]: ${jsonParseFailureMessage("")}`,
tool: "vault_delete_note",
requestId: deleteCallLog?.requestId,
})
}, 30_000)
})
164 changes: 164 additions & 0 deletions src/__tests__/integration/server-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
callTool,
textContent,
} from "./test-harness.js"
import type { ToolResult } from "./test-harness.js"

vi.setConfig({ testTimeout: 15_000 })

Expand Down Expand Up @@ -1949,6 +1950,169 @@ describe("trash retention over real HTTP", () => {
* structured stdout stream. */
const SWEEP_LOG_MARKER = '"message":"trash retention sweep complete"'

// ── Settings read on every call ───────────────────────────────
//
// Each scenario boots its own server and rewrites a .obsidian settings file
// while it runs. The fixture vault has no daily-notes.json, and its Tasks
// plugin config sets only the status registry.

describe("daily notes folder read on every call", () => {
it("delete, move and the daily-note tool follow a daily-notes.json folder change", async () => {
const server = await startServer(await freePort())
onTestFinished(() => server.cleanup())
const client = await createTestClient(server.port)
onTestFinished(() => client.close())
const dailyNotesConfigPath = join(server.vaultPath, ".obsidian", "daily-notes.json")
await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Journal" }), "utf8")
for (const path of ["Journal/entry.md", "Diary/entry.md"]) {
await callTool({ client, name: "vault_write_note", args: { path, body: "kept" } })
}

const refusedWhileJournal = await callTool({
client,
name: "vault_delete_note",
args: { path: "Journal/entry.md" },
})
const dailyNoteWhileJournal = await callTool({
client,
name: "vault_get_daily_note",
args: { date: "2026-01-15" },
})

expect(refusedWhileJournal.isError).toBe(true)
expect(textContent(refusedWhileJournal)).toBe(
'[Error]: cannot delete protected path "Journal/entry.md"',
)
expect(JSON.parse(textContent(dailyNoteWhileJournal))).toEqual({
path: "Journal/2026-01-15.md",
content: null,
exists: false,
})

await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Diary" }), "utf8")

const deletedAfterChange = await callTool({
client,
name: "vault_delete_note",
args: { path: "Journal/entry.md" },
})
const refusedAfterChange = await callTool({
client,
name: "vault_delete_note",
args: { path: "Diary/entry.md" },
})
const moveRefusedAfterChange = await callTool({
client,
name: "vault_move_note",
args: { old_path: "Diary/entry.md", new_path: "Scratch/entry.md" },
})
const dailyNoteAfterChange = await callTool({
client,
name: "vault_get_daily_note",
args: { date: "2026-01-15" },
})

expect(deletedAfterChange.isError).not.toBe(true)
expect(textContent(deletedAfterChange)).toBe(
"Moved Journal/entry.md to trash (.trash/Journal/entry.md)",
)
expect(refusedAfterChange.isError).toBe(true)
expect(textContent(refusedAfterChange)).toBe(
'[Error]: cannot delete protected path "Diary/entry.md"',
)
expect(moveRefusedAfterChange.isError).toBe(true)
expect(textContent(moveRefusedAfterChange)).toBe(
'[Error]: cannot move protected path "Diary/entry.md"',
)
expect(JSON.parse(textContent(dailyNoteAfterChange))).toEqual({
path: "Diary/2026-01-15.md",
content: null,
exists: false,
})
}, 30_000)
})

describe("Tasks plugin settings read on every task write", () => {
it("a format change reaches the next write while the boot-time status registry stays", async () => {
const server = await startServer(await freePort())
onTestFinished(() => server.cleanup())
const client = await createTestClient(server.port)
onTestFinished(() => client.close())
const pluginConfigPath = join(
server.vaultPath,
".obsidian",
"plugins",
"obsidian-tasks-plugin",
"data.json",
)
const createTask = (blockId: string): Promise<ToolResult> => {
return callTool({
client,
name: "vault_create_task",
args: {
path: "Projects/alpha.md",
description: `Format probe ${blockId}`,
block_id: blockId,
heading: "Tasks",
due: "2026-08-01",
},
})
}
// The section also holds the fixture's own tasks, so each written line
// is checked as a fragment.
const readTasksSection = async (): Promise<string> => {
const readback = await callTool({
client,
name: "vault_read_note",
args: { path: "Projects/alpha.md", heading: "Tasks" },
})
return textContent(readback)
}

const emojiCreate = await createTask("format-emoji")

expect(emojiCreate.isError).not.toBe(true)
expect(await readTasksSection()).toContain(
`- [ ] Format probe format-emoji ➕ ${DateTime.now().toISODate()} 📅 2026-08-01 ^format-emoji`,
)

// The status registry the fixture ships is rewritten so ">" is a plain
// TODO, alongside the format switch.
await writeFile(
pluginConfigPath,
JSON.stringify({
taskFormat: "dataview",
statusSettings: {
coreStatuses: [
{ symbol: " ", name: "Todo", nextStatusSymbol: "x", type: "TODO" },
{ symbol: "x", name: "Done", nextStatusSymbol: " ", type: "DONE" },
],
customStatuses: [{ symbol: ">", name: "Forwarded", nextStatusSymbol: " ", type: "TODO" }],
},
}),
"utf8",
)

const dataviewCreate = await createTask("format-dataview")
const nonTaskUpdate = await callTool({
client,
name: "vault_update_task",
args: { path: "Projects/status-registry.md", block_id: "forwarded-ref", status: "done" },
})

expect(dataviewCreate.isError).not.toBe(true)
expect(await readTasksSection()).toContain(
`- [ ] Format probe format-dataview [created:: ${DateTime.now().toISODate()}] [due:: 2026-08-01] ^format-dataview`,
)
// Writes classify with the registry the index was built with, so the
// retyped file changes nothing until a restart.
expect(nonTaskUpdate.isError).toBe(true)
expect(textContent(nonTaskUpdate)).toBe(
'[Error]: checkbox "[>]" is a NON_TASK status in the Tasks plugin registry',
)
}, 30_000)
})

describe("trash retention sweep gating", () => {
it("the startup sweep runs under the default config", async () => {
const server = await startServer(await freePort())
Expand Down
5 changes: 3 additions & 2 deletions src/utils/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isErrnoException } from "./is-errno-exception.js"
* missing (ENOENT), or a component on the way to it is a file rather than a
* folder (ENOTDIR). Both read as "not there" to a caller that asked by path;
* the raw message names the absolute path, so it never leaves as an error. */
const isMissingPathError = (error: unknown): boolean => {
export const isMissingPathError = (error: unknown): boolean => {
return isErrnoException(error, "ENOENT") || isErrnoException(error, "ENOTDIR")
}

Expand Down Expand Up @@ -59,8 +59,9 @@ export const readdirOrNull = async (path: string): Promise<Dirent[] | null> => {
// while it runs. Null would report that listing as empty, so it is
// returned only when the listed path itself is not a folder.
const pathStats = await statOrNull(path)
const listedPathIsDirectory = pathStats?.isDirectory() ?? false

if (pathStats?.isDirectory()) throw error
if (listedPathIsDirectory) throw error
return null
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/utils/is-record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** True for a plain object (not null, not an array), so parsed JSON can be
* read by key. */
export const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
Loading
Loading