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
91 changes: 87 additions & 4 deletions src/__tests__/integration/server-error-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1692,7 +1692,7 @@ describe("task errors", () => {
)
})

it("vault_create_task with invalid date", async () => {
it("vault_create_task with a date the calendar lacks", async () => {
const result = await callTool({
client,
name: "vault_create_task",
Expand All @@ -1703,10 +1703,18 @@ describe("task errors", () => {
due: "2026-02-30",
},
})
expectToolError(result, "invalid date")
expect(result).toEqual({
content: [
{
type: "text",
text: '[Error]: invalid date: due "2026-02-30" is not a calendar date. Pass a real date in YYYY-MM-DD format.',
},
],
isError: true,
})
})

it("vault_update_task with invalid date", async () => {
it("vault_update_task with a malformed date", async () => {
const result = await callTool({
client,
name: "vault_update_task",
Expand All @@ -1716,7 +1724,10 @@ describe("task errors", () => {
due: "not-a-date",
},
})
expectToolError(result, "invalid date")
expect(result).toEqual({
content: [{ type: "text", text: '[Error]: invalid date: due "not-a-date" (use YYYY-MM-DD)' }],
isError: true,
})
})

it("vault_update_task — cannot move a sub-task to a heading", async () => {
Expand Down Expand Up @@ -2535,3 +2546,75 @@ describe("tag must not be empty", () => {
})
})
})

// ── Date filter values ───────────────────────────────────────

describe("date filter values", () => {
it("vault_search rejects a date filter not in YYYY-MM-DD form", async () => {
const result = await callTool({
client,
name: "vault_search",
args: { query: "note", filters: { modified: { after: "yesterday" } } },
})
expect(result).toEqual({
content: [
{
type: "text",
text: '[Error]: invalid modified.after date: "yesterday". Use YYYY-MM-DD (e.g. 2026-07-03).',
},
],
isError: true,
})
})

it("vault_search rejects a date filter naming a day the calendar lacks", async () => {
const result = await callTool({
client,
name: "vault_search",
args: { query: "note", filters: { created: { on: "2026-02-30" } } },
})
expect(result).toEqual({
content: [
{
type: "text",
text: '[Error]: created.on date "2026-02-30" is not a calendar date. Pass a real date in YYYY-MM-DD format.',
},
],
isError: true,
})
})

it("vault_list_tasks rejects a date filter not in YYYY-MM-DD form", async () => {
const result = await callTool({
client,
name: "vault_list_tasks",
args: { due: { before: "July 3rd" } },
})
expect(result).toEqual({
content: [
{
type: "text",
text: '[Error]: invalid due.before date: "July 3rd". Use YYYY-MM-DD (e.g. 2026-07-03).',
},
],
isError: true,
})
})

it("vault_list_tasks rejects a date filter naming a day the calendar lacks", async () => {
const result = await callTool({
client,
name: "vault_list_tasks",
args: { done: { after: "2026-04-31" } },
})
expect(result).toEqual({
content: [
{
type: "text",
text: '[Error]: done.after date "2026-04-31" is not a calendar date. Pass a real date in YYYY-MM-DD format.',
},
],
isError: true,
})
})
})
33 changes: 33 additions & 0 deletions src/utils/__tests__/classify-iso-date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest"
import { classifyIsoDate } from "../classify-iso-date.js"

describe("classifyIsoDate", () => {
it.each([
{ label: "an ordinary day", text: "2026-07-03" },
{ label: "February 29 in a leap year", text: "2024-02-29" },
])("classifies $label as a calendar date", ({ text }) => {
expect(classifyIsoDate(text)).toBe("calendar-date")
})

it.each([
{ label: "February 30", text: "2026-02-30" },
{ label: "February 29 outside a leap year", text: "2025-02-29" },
{ label: "month 13", text: "2026-13-01" },
{ label: "month 00", text: "2026-00-10" },
{ label: "day 00", text: "2026-02-00" },
])("classifies $label as not a calendar date", ({ text }) => {
expect(classifyIsoDate(text)).toBe("not-a-calendar-date")
})

it.each([
{ label: "a single-digit month and day", text: "2026-7-3" },
{ label: "a timestamp", text: "2026-07-03T10:30:00" },
{ label: "a five-digit year", text: "20260-01-01" },
{ label: "slashes", text: "2026/07/03" },
{ label: "a month name", text: "March 10" },
{ label: "surrounding whitespace", text: " 2026-07-03" },
{ label: "an empty string", text: "" },
])("classifies $label as malformed", ({ text }) => {
expect(classifyIsoDate(text)).toBe("malformed")
})
})
21 changes: 21 additions & 0 deletions src/utils/classify-iso-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DateTime } from "luxon"

type IsoDateClass = "calendar-date" | "malformed" | "not-a-calendar-date"

/** Classifies text by whether it names a real day in YYYY-MM-DD form. The two failures:
* - "malformed": not in that form, such as "2026-7-3", "March 10" or a timestamp
* - "not-a-calendar-date": in that form, but the month or day doesn't exist,
* such as 2026-02-30 or 2026-13-01
* They stay apart so an error message can name the failure: a client told only
* "use YYYY-MM-DD" sends 2026-02-30 again, since it already used that form. */
export const classifyIsoDate = (text: string): IsoDateClass => {
const parsedDate = DateTime.fromFormat(text, "yyyy-MM-dd")

if (parsedDate.isValid) return "calendar-date"

// Luxon checks the form first ("unparsable" when it doesn't match), then the
// calendar ("unit out of range" for a month or day that doesn't exist)
if (parsedDate.invalidReason === "unit out of range") return "not-a-calendar-date"
Comment thread
aliasunder marked this conversation as resolved.

return "malformed"
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@
{
"name": "vault_list_tasks",
"title": "List Tasks",
"description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.",
"description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- \"invalid due.before date: …\" (any date filter's bound) — the bound isn't in YYYY-MM-DD form; use that form\n- \"… is not a calendar date\" — the bound has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\n- \"path must end in .md\" — add the \".md\" extension\n- tag must not be empty after its leading \"#\" — tag is \"#\" alone; pass the tag name\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.",
"inputSchema": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1285,7 +1285,7 @@
{
"name": "vault_search",
"title": "Search Notes",
"description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.",
"description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: every listed frontmatter tag must match, in any letter case; \"project\" also matches \"project/a\"\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- \"invalid created.on date: …\" (any created/modified bound) — the bound isn't in YYYY-MM-DD form; use that form\n- \"… is not a calendar date\" — the bound has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\n- tag must not be empty after its leading \"#\" — a tags entry is only \"#\"; pass the tag name\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
Loading
Loading