Tasks Tracker
tasks-tracker is ADR 0001’s flagship use case: a tracker whose records mirror this repository’s own docs/planning/tasks/*.md frontmatter — compound statuses like open/ready, an epic wikilink, tags, and a markdown body of sections — served two ways from one generated pipeline.
It’s the example to read if you care about the MCP transport, because it’s the one that exercises the generated tool registry end to end.
The source lives at examples/tasks-tracker/.
Over HTTP
Section titled “Over HTTP”cargo run # PORT=39102 cargo run serves on another portTasks Tracker: the wire shows the real requests and responses, captured from a running server.
curl -s localhost:3002/api/tasks -H 'accept: application/vnd.api+json' | jq
# POST without an id -- the slug derives from the titlecurl -s -X POST localhost:3002/api/tasks \ -H 'content-type: application/vnd.api+json' \ -d '{"data":{"type":"tasks","attributes":{"title":"Review the stack","status":"open/ready","created":"2026-06-06","body":"## Goal\n\nReview.\n"},"relationships":{"epic":{"data":{"type":"epics","id":"markdown-backend"}},"tags":{"data":[{"type":"tags","id":"codegen"}]}}}}'
cat data/vault/tasks/review-the-stack.md
# Filter the list (-g keeps curl from reading the brackets as a URL glob)curl -sg 'localhost:3002/api/tasks?filter[status]=closed/done&filter[epic_id]=markdown-backend' \ -H 'accept: application/vnd.api+json' | jq
# Sort it: newest first, then by title (the id is always the last key)curl -sg 'localhost:3002/api/tasks?filter[status]=closed/done&sort=-created,title' \ -H 'accept: application/vnd.api+json' | jq '.data[].id'
# Add a tag through the relationship endpoint, then read the related tagscurl -si -X POST localhost:3002/api/tasks/ship-the-emitter/relationships/tags \ -H 'content-type: application/vnd.api+json' \ -d '{"data":[{"type":"tags","id":"release"}]}'curl -s localhost:3002/api/tasks/ship-the-emitter/tags -H 'accept: application/vnd.api+json' | jq
# Make review-the-stack a subtask of ship-the-emittercurl -si -X PATCH localhost:3002/api/tasks/review-the-stack/relationships/parent \ -H 'content-type: application/vnd.api+json' \ -d '{"data":{"type":"tasks","id":"ship-the-emitter"}}'curl -s localhost:3002/api/tasks/ship-the-emitter/relationships/subtasks \ -H 'accept: application/vnd.api+json' | jqLists page 20 at a time (GET /api/tasks?page[offset]=0&page[limit]=5 answers with meta.total and links.next), because build.rs sets pagination and .api_paginated(..) for task, epic and tag. The MCP list tools take limit and offset and return {items, total, limit, offset}; task_list also takes the filter’s fields as arguments.
The task list is hand-written. src/api/v1/task.rs re-exports the generated task module and defines its own list and count, which take a ListTasksQuery { status, epic_id } and an order: &[OrderBy<TaskSortField>]. The generated list and count are then left out, the HTTP route reads filter[status] and filter[epic_id], sort reads into the order, and meta.total is the hand-written count of the filtered set. The sort fields are id, title, status and created. The links repeat the filter and the sort in canonical order:
"self": "/api/tasks?filter%5Bepic_id%5D=markdown-backend&filter%5Bstatus%5D=closed%2Fdone&sort=-created,title&page%5Boffset%5D=0&page%5Blimit%5D=20"An unknown member such as filter[colour] is 400 invalid_query_parameter, and an unknown sort field such as sort=priority is 400 invalid_sort_field. The generated handlers import the struct from types_import_path, so src/schema/mod.rs re-exports it. See the filtered lists recipe.
The POST response is 201 with a Location: /api/tasks/review-the-stack header and the new task as a JSON:API resource: the fields are under attributes, and epic, tags, parent and subtasks are under relationships, each with links to its relationship endpoint (/api/tasks/review-the-stack/relationships/epic) and its related resource (/api/tasks/review-the-stack/epic). The relationship endpoints change one relationship at a time and answer 204; parent and subtasks are two views of one foreign key, so setting a task’s parent adds it to the parent’s subtasks. The filename came from the title. The store’s IdStrategy::SlugFromField("title") (set with .store_id_strategy(..) in build.rs) slugifies the configured field when the caller omits an id, de-duplicating with -2, -3, … suffixes — atomically with the write, so two concurrent creates can’t collide on a filename.
Over MCP
Section titled “Over MCP”The same pipeline emits an MCP tool registry:
cargo run -- mcp-tools # every entity op as a tool, with JSON schemascargo run -- mcp-call task_list '{}' # agent-shaped reads over the vaultcargo run -- mcp-call task_list '{"status":"closed/done"}' # the list's filter, as tool argumentscargo run -- mcp-call task_list '{"sort":["-created","title"]}' # newest first, then by titlecargo run -- mcp-call task_create '{"title":"From an agent","status":"open","created":"2026-06-06","body":"…"}'generated_tool_registry() is transport-agnostic — name, description, JSON schema, and an async handler per tool — so wiring it into any MCP server runtime is a loop over the registry. The CLI here dispatches it directly to keep the example dependency-free.
The schema
Section titled “The schema”Three entities, shaped by the corpus rather than by what’s convenient to generate:
#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]#[ontology(entity, directory = "tasks", table = "tasks")]pub struct Task { #[ontology(id)] pub id: String,
pub title: String,
/// e.g. `open/ready`, `in-progress`, `closed/done` -- compound statuses /// are plain strings, exactly as the corpus writes them. pub status: String,
pub created: String,
#[ontology(relation(belongs_to, target = "Epic"))] pub epic_id: Option<String>,
#[ontology(relation(many_to_many, target = "Tag"))] pub tags: Vec<String>,
#[ontology(relation(belongs_to, target = "Task"))] pub parent_id: Option<String>,
#[ontology(relation(has_many, target = "Task", foreign_key = "parent_id"))] pub subtasks: Vec<String>,
#[ontology(body)] pub body: String,}Note what status isn’t: an enum_field. The real corpus uses compound values like open/ready and closed/wontdo, and forcing those into a Rust enum would mean the schema dictating vocabulary to the vault rather than describing it. Plain String keeps the files authoritative.
Epic deliberately has no task list. Which tasks belong to an epic is a derived question — walk tasks/, filter on epic_id — and storing it on the epic would create a second place for the truth to live. The vault stays greppable, diffable, and Obsidian-navigable; the tracker is one lens over it.
Why this shape matters
Section titled “Why this shape matters”The vault is a plain folder of markdown. That means:
- An agent can edit records through MCP and you can edit the same records in Obsidian, in the same session, without a sync step.
git logondata/vault/tasks/is the tracker’s audit trail, for free.grep -r 'open/ready' data/vaultworks.
The generated API is a convenience over the files, not a gatekeeper in front of them.
Where to look
Section titled “Where to look”src/schema/task.rs— the corpus-shaped entity, and the reasoning above in comment form.build.rs—markdown_io+HttpAxum+Mcpin onePipeline, ~50 lines.src/api/transport/mcp/generated.rs— the tool registry.data/vault/— the records.