Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cd80e04
fix(tools): cut repeated text from five tool definitions
aliasunder Oct 3, 2026
68a7ecb
fix(tools): cut schema restatements from the three task tool definitions
aliasunder Oct 3, 2026
a141e5d
fix(tools): keep what a hidden path is, and move two repeated facts t…
aliasunder Oct 3, 2026
f3dd25f
fix(tools): one targeted cut per repeated fact in nineteen tool defin…
aliasunder Oct 3, 2026
89d2f34
test(tools): lower the tool-list size allowance to the new average pl…
aliasunder Oct 3, 2026
b1339d0
test(tools): pin the outline response shape on the outline parameter
aliasunder Oct 3, 2026
53eeb60
fix(tools): restore two vault_list_files facts an audit found missing
aliasunder Oct 3, 2026
fa2a8d9
test(tools): set the size allowance from the final average
aliasunder Oct 3, 2026
08c6cac
fix(tools): list errors the client can receive, and drop four it cannot
aliasunder Oct 3, 2026
418b046
fix(tools): correct error entries a reachability review found inexact
aliasunder Oct 3, 2026
43efd4c
fix(tools): list the remaining input-reachable errors and set the fin…
aliasunder Oct 3, 2026
633d9ee
style: clarify ambiguous referents and dense clauses in tool definitions
aliasunder Oct 3, 2026
b335bb4
fix(tools): cut the remaining description and schema repeats
aliasunder Oct 3, 2026
5525a57
fix(tools): remove phantom "cancelled" sort option from vault_list_tasks
aliasunder Oct 3, 2026
17f3208
docs: list the remaining unsupported daily-note format tokens in the …
aliasunder Oct 3, 2026
a407b7b
fix(tools): give vault_update_memory's shrink-guard error a remedy th…
aliasunder Oct 3, 2026
cfc6c72
test: pin the empty listing for an empty extensions array in vault_li…
aliasunder Oct 3, 2026
0f5f568
fix(tools): drop vault_create_task's vault_update_task reference when…
aliasunder Oct 3, 2026
5050146
test(search): pin that vault_memory_recall applies the file filter be…
aliasunder Oct 4, 2026
2f702d0
fix(tools): state that vault_delete_span collapses every blank-line r…
aliasunder Oct 4, 2026
e7e6f1e
fix(tools): say that a vault_patch_note section includes its child he…
aliasunder Oct 4, 2026
b5f9817
fix(tools): reject empty filter lists and hide unreachable trash erro…
aliasunder Oct 5, 2026
7a81a63
fix(tools): say that vault_list_tasks reads the index and lists only …
aliasunder Oct 5, 2026
2dc292e
fix(tools): quote the anchor errors the span tools send
aliasunder Oct 5, 2026
6b6b914
fix(tools): give raw and line paging their own blocks in vault_read_file
aliasunder Oct 5, 2026
49e72f6
fix(tools): say that a canvas can exceed vault_read_file's text outpu…
aliasunder Oct 5, 2026
4cfebca
fix(tools): gate a tool reference, state replace_in_note's blank-line…
aliasunder Oct 5, 2026
f6f6780
ci: restart CodeQL on the PR head
aliasunder Oct 5, 2026
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
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,11 +818,11 @@ which are the description and the input schema with each parameter's Zod
which lists only the messages that tool can raise: for example
`"absolute path blocked"`, `"path traversal blocked"`, and
`"hidden path blocked"`.
- **Parameter text has one home per kind of fact.** The calling model
receives each parameter's Zod `.describe()` text inside the input
schema, so the tool description earns its space (and its score from
graders such as Glama's Tool Definition Quality Score, TDQS) only
with what that text doesn't say:
- **Split parameter facts between `.describe()` and the tool description.**
The calling model receives each parameter's Zod `.describe()` text
inside the input schema, so the tool description earns its space
(and its score from graders such as Glama's Tool Definition Quality
Score, TDQS) only with what that text doesn't say:
- `.describe()` gives the parameter's plain meaning: what it is,
its format, and its default.
- The description gives what `.describe()` can't: interactions
Expand All @@ -841,6 +841,9 @@ which are the description and the input schema with each parameter's Zod
second copy of a guard the data layer must enforce regardless (drift
risk). `.min(1)` is the floor because it does serialize (`minLength`)
and its default failure message is self-explanatory.
- **Edit only the tools a change is meant for.** A grader re-scores
every definition whose text changes, so apply a shared wording change
tool by tool and leave every other tool's text as it is.
- **`tool-surface-snapshot.test.ts` caps the tool list's size**, because
clients such as claude.ai load every definition into each
conversation. Each checked combo's total must stay within
Expand Down Expand Up @@ -1118,7 +1121,8 @@ test.
- New config gating axis → config matrix test in
`server-integration.test.ts` (tool count + key behavior), and add the
axis to `SURFACE_AXES` in `scripts/tool-surface-capture.ts` so the
snapshot combos cover it.
snapshot combos cover it. For a setting that changes only one tool's
text, add a single combo to `SURFACE_COMBOS` instead.
- New prompt → assembly test verifying live vault data, not just the
instruction wrapper.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ When the file isn't available — or if you use the Periodic Notes plugin, whose

You can set one or both — a set value always wins over the config file. Without either source, the server falls back to `Daily Notes` and `YYYY-MM-DD`.

> **Note:** A few date format tokens are unsupported — ordinals (`Do`, `Mo`, `DDDo`, `wo`), `dd` (2-letter weekday), `d` (weekday number), `e`, `k`/`kk`, and the localized formats (`L`–`LLLL`, `LT`, `LTS`). The server can't reproduce the filenames Obsidian creates with these tokens, so it could never find the notes. If your format uses any of them, `vault_get_daily_note` returns a clear error — change the format in Obsidian or set `DAILY_NOTES_FORMAT` to a supported alternative.
> **Note:** A few date format tokens are unsupported — ordinals (`Do`, `Mo`, `DDDo`, `wo`), `dd` (2-letter weekday), `d` (weekday number), `e`, `k`/`kk`, `w` (week number), `Q` (quarter), `Z`/`ZZ` (UTC offset), and the localized formats (`L`–`LLLL`, `LT`, `LTS`). The server can't reproduce the filenames Obsidian creates with these tokens, so it could never find the notes. If your format uses any of them, `vault_get_daily_note` returns a clear error — change the format in Obsidian or set `DAILY_NOTES_FORMAT` to a supported alternative.

---

Expand Down
31 changes: 19 additions & 12 deletions scripts/tool-surface-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ type SurfaceAxis = {
label: string
}

/** Boolean config axes that change the registered tool surface or its rendered
* text. The snapshot combos are the cross-product of this array — a new
* gating axis in config.ts must be added here, or its states go unpinned. */
/** Boolean config axes that change which tools are registered or the text of
* several tools. The snapshot combos are the cross-product of this array — a
* new gating axis in config.ts must be added here, or its states go unpinned.
* A setting that changes one tool's text gets a single combo in
* SURFACE_COMBOS instead. */
const SURFACE_AXES: readonly SurfaceAxis[] = [
{ envVar: "READONLY_MODE", flippedValue: "true", label: "readonly" },
{ envVar: "MEMORY_ENABLED", flippedValue: "false", label: "memory-off" },
Expand Down Expand Up @@ -71,14 +73,19 @@ const comboFromFlippedAxes = (flippedAxes: readonly SurfaceAxis[]): SurfaceCombo
}
}

/** The 16 axis combos plus one DISABLED_TOOLS representative:
* - vault_patch_note is cross-referenced from other tools' descriptions, so
* its combo verifies those references disappear when the tool is disabled.
/** The 16 axis combos plus two single-setting representatives:
* - Conjunction combos pin rendered states that exist only in multi-flip
* configs (several description clauses drop together). */
* configs (several description clauses drop together).
* - disabled-tools: vault_patch_note is cross-referenced from other tools'
* descriptions, so its combo verifies those references disappear when the
* tool is disabled.
* - obsidian-sync: OBSIDIAN_SYNC changes only vault_delete_note's Errors
* list, so one combo pins it; crossing it with the axes would double the
* baseline without adding a rendered state. */
export const SURFACE_COMBOS: readonly SurfaceCombo[] = [
...axisSubsets.map(comboFromFlippedAxes),
{ name: "disabled-tools", env: { DISABLED_TOOLS: "vault_patch_note" } },
{ name: "obsidian-sync", env: { OBSIDIAN_SYNC: "true" } },
]

const noop = (): void => {}
Expand Down Expand Up @@ -192,11 +199,11 @@ export const measureToolListChars = (tools: readonly Tool[]): number => {
* - embedding-off renders the search tools' keyword-only text, which default
* never shows.
*
* Every other combo drops tools or cross-references from one of these two, so
* its total is smaller than a checked total. Its average per tool can still
* exceed that cap's `CHARS_PER_TOOL_ALLOWANCE` (memory-off drops five small
* tools), so the allowance bounds the two checked lists, not every combo's
* average. */
* Every other combo drops tools, cross-references, or Errors entries from one
* of these two, so its total is smaller than a checked total. Its average per
* tool can still exceed that cap's `CHARS_PER_TOOL_ALLOWANCE` (memory-off
* drops five small tools), so the allowance bounds the two checked lists, not
* every combo's average. */
export const SIZE_CAPPED_COMBO_NAMES = ["default", "embedding-off"] as const

/** Byte-exact committed form: pre-serialized so vitest writes the file
Expand Down
234 changes: 233 additions & 1 deletion src/__tests__/integration/server-error-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* verified over real HTTP transport against a real server. */

import { describe, it, expect, beforeAll, afterAll, onTestFinished, vi } from "vitest"
import { readFile, writeFile } from "node:fs/promises"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { join } from "node:path"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
Expand Down Expand Up @@ -571,6 +571,24 @@ describe("note already exists", () => {
})
})

// ── Path is not a file ───────────────────────────────────────

describe("path is not a file", () => {
it("vault_write_note refuses a path that a folder occupies", async () => {
const folderPath = "Folder Named Like A Note.md"
const folderFullPath = join(serverVaultPath, folderPath)
await mkdir(folderFullPath)
onTestFinished(() => rm(folderFullPath, { recursive: true }))

const result = await callTool({
client,
name: "vault_write_note",
args: { path: folderPath, body: "refused" },
})
expectToolError(result, `cannot write note "${folderPath}": that path is not a file`)
})
})

// ── Heading not found ────────────────────────────────────────

describe("heading not found", () => {
Expand Down Expand Up @@ -784,6 +802,220 @@ describe("memory errors", () => {
})
expectToolError(result, 'section not found: "Missing Section" in About Me/Preferences.md')
})

it("vault_delete_memory with a nonexistent file", async () => {
const result = await callTool({
client,
name: "vault_delete_memory",
args: {
file: "Nonexistent",
section: "Editor settings",
date: "2026-01-01",
entry: "anything",
},
})
expectToolError(result, 'memory file not found: "About Me/Nonexistent.md"')
})

it("vault_get_memory with a folder in the file name", async () => {
const result = await callTool({
client,
name: "vault_get_memory",
args: { file: "Nested/Preferences" },
})
expectToolError(
result,
'memory file must be a bare name without path separators: "Nested/Preferences"',
)
})

it("vault_update_memory with a folder in the file name", async () => {
const result = await callTool({
client,
name: "vault_update_memory",
args: { file: "Nested/Preferences", section: "Editor settings", entry: "never written" },
})
expectToolError(
result,
'memory file must be a bare name without path separators: "Nested/Preferences"',
)
})

it("vault_update_memory with a control character in the entry", async () => {
const result = await callTool({
client,
name: "vault_update_memory",
args: { file: "Preferences", section: "Editor settings", entry: "bell\u0007inside" },
})
expectToolError(result, "entry contains a control character (U+0007 at position 4)")
})

it("vault_update_memory with a control character in the section", async () => {
const result = await callTool({
client,
name: "vault_update_memory",
args: { file: "Preferences", section: "Editor\u0007settings", entry: "never written" },
})
expectToolError(result, "section contains a control character (U+0007 at position 6)")
})

it("vault_delete_memory with a folder in the file name", async () => {
const result = await callTool({
client,
name: "vault_delete_memory",
args: {
file: "Nested/Preferences",
section: "Editor settings",
date: "2026-01-01",
entry: "anything",
},
})
expectToolError(
result,
'memory file must be a bare name without path separators: "Nested/Preferences"',
)
})
})

// ── Parameter combinations ───────────────────────────────────

describe("parameter combinations", () => {
it("vault_read_note with heading_level but no heading", async () => {
const result = await callTool({
client,
name: "vault_read_note",
args: { path: "Projects/alpha.md", heading_level: 2 },
})
expectToolError(result, "heading_level requires a heading")
})

it("vault_read_note with a whitespace-only heading", async () => {
const result = await callTool({
client,
name: "vault_read_note",
args: { path: "Projects/alpha.md", heading: " " },
})
expectToolError(result, "heading cannot be empty")
})

it("vault_patch_note with a whitespace-only heading", async () => {
const result = await callTool({
client,
name: "vault_patch_note",
args: { path: "Projects/alpha.md", operation: "append", heading: " ", content: "refused" },
})
expectToolError(result, "heading cannot be empty")
})

it("vault_move_note onto its own path", async () => {
const result = await callTool({
client,
name: "vault_move_note",
args: { old_path: "Projects/alpha.md", new_path: "Projects/alpha.md" },
})
expectToolError(result, "source and destination are the same path")
})
})

// ── Task tool path guards ────────────────────────────────────

describe("task tool path guards", () => {
const blockedPathCases = [
{ label: "an absolute path", path: "/TASKS.md", message: "absolute path blocked" },
{ label: "a path outside the vault", path: "../TASKS.md", message: "path traversal blocked" },
{ label: "a hidden path", path: ".obsidian/TASKS.md", message: "hidden path blocked" },
]

it.each(blockedPathCases)("vault_create_task rejects $label", async ({ path, message }) => {
const result = await callTool({
client,
name: "vault_create_task",
args: { path, description: "never written", block_id: "never-written" },
})
expectToolError(result, message)
})

it.each(blockedPathCases)("vault_update_task rejects $label", async ({ path, message }) => {
const result = await callTool({
client,
name: "vault_update_task",
args: { path, block_id: "never-read", status: "done" },
})
expectToolError(result, message)
})
})

// ── Malformed canvas ─────────────────────────────────────────

describe("malformed canvas", () => {
it("vault_read_file on a .canvas that is not valid JSON", async () => {
const canvasPath = "Not A Canvas.canvas"
const canvasFullPath = join(serverVaultPath, canvasPath)
await writeFile(canvasFullPath, "{ not json", "utf8")
onTestFinished(() => rm(canvasFullPath))

const result = await callTool({ client, name: "vault_read_file", args: { path: canvasPath } })
expectToolError(result, "invalid .canvas JSON")
})
})

// ── Whitespace-only task text ────────────────────────────────

describe("whitespace-only task text", () => {
it("vault_create_task with a whitespace-only description", async () => {
const result = await callTool({
client,
name: "vault_create_task",
args: { path: "Projects/alpha.md", description: " ", block_id: "blank-description" },
})
expectToolError(result, "description is empty")
})

it("vault_create_task with a whitespace-only checklist item", async () => {
const result = await callTool({
client,
name: "vault_create_task",
args: {
path: "Projects/alpha.md",
description: "Card with a blank checklist item",
block_id: "blank-checklist-item",
subtasks: [" "],
},
})
expectToolError(result, "subtasks cannot contain an empty item")
})

it("vault_update_task with a whitespace-only description", async () => {
const result = await callTool({
client,
name: "vault_update_task",
args: { path: "Projects/alpha.md", block_id: "alpha-task-1", description: " " },
})
expectToolError(result, "description cannot be empty")
})

it("vault_update_task with a whitespace-only checklist item", async () => {
const result = await callTool({
client,
name: "vault_update_task",
args: { path: "Projects/alpha.md", block_id: "alpha-task-1", add_subtasks: [" "] },
})
expectToolError(result, "addSubtasks cannot contain an empty item")
})
})

// ── Undecodable image ────────────────────────────────────────

describe("undecodable image", () => {
it("vault_read_file on a .png that holds no image data", async () => {
const imagePath = "Not Really An Image.png"
const imageFullPath = join(serverVaultPath, imagePath)
await writeFile(imageFullPath, "plain text, not image bytes", "utf8")
onTestFinished(() => rm(imageFullPath))

const result = await callTool({ client, name: "vault_read_file", args: { path: imagePath } })
expectToolError(result, "Input buffer contains unsupported image format")
})
})

// ── Task errors ──────────────────────────────────────────────
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/integration/server-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1890,6 +1890,37 @@ describe("trash retention over real HTTP", () => {
expect(readTrashEntryRows(server.dataDir)).toEqual([])
}, 30_000)

it("a sync-mode delete succeeds with an unreadable Deleted files setting and a blocked .trash", async () => {
// vault_delete_note's description leaves its trash errors out under Sync.
// Without Sync, the first condition fails the delete with "cannot read
// trash config" and the second with "cannot move to trash".
const server = await startServer(await freePort(), {
OBSIDIAN_SYNC: "true",
})
onTestFinished(() => server.cleanup())
await writeFile(join(server.vaultPath, ".obsidian", "app.json"), "{ not json", "utf8")
await writeFile(join(server.vaultPath, ".trash"), "a file where the trash folder goes", "utf8")
const client = await createTestClient(server.port)
onTestFinished(() => client.close())

await callTool({
client,
name: "vault_write_note",
args: { path: "Scratch/sync-blocked-trash.md", body: "gone for good" },
})
const deleteResult = await callTool({
client,
name: "vault_delete_note",
args: { path: "Scratch/sync-blocked-trash.md" },
})

expect(deleteResult.isError).not.toBe(true)
expect(textContent(deleteResult)).toBe("Deleted Scratch/sync-blocked-trash.md")
await expect(
fileExists(join(server.vaultPath, "Scratch", "sync-blocked-trash.md")),
).resolves.toBe(false)
}, 30_000)

it("a Deleted files setting changed between two deletes is followed without a restart", async () => {
const server = await startServer(await freePort())
onTestFinished(() => server.cleanup())
Expand Down
Loading
Loading