On August 6, 2026 I replaced my project management tool by editing one line in a config file. The line told my coding agent which MCP server to call. Before the edit it said Leantime. After it, Petrichor, a Drupal 11 site I had started building two days earlier.
Today, 75 days later, that site holds 56 projects, 436 tasks, 57 agent memories and 94 timesheet entries totaling 219.8 hours. Every project I work on runs through it, and most of the writing in it was done by an agent, not by me. This is a case study: what the problem was, what I built, what the numbers say, and what broke.
TL;DR
- The problem was never the kanban board. It was that an agent couldn't do everything the UI could, because the API was maintained beside the app instead of underneath it.
- Petrichor is 11 Drupal submodules and a theme, about 18,600 lines of PHP, with the MCP server built before the UI. Tools, UI and drush all call one service layer.
- Cutover took a 16-check parity suite, a tested restore from backup, and one import: 6 milestones, 54 tasks, 24 subtasks, 0 orphans.
- One outage in 75 days. Root cause was in my code, not the database, and the fix was a default flipped to off.
- Source is on drupal.org and mirrored at github.com/joehahndev/petrichor.
The problem
I spent a decade at an agency with project managers. Then I went freelance and discovered that project management was a job I had never done. Not tracking. Deciding what a piece of work is, continuously, across a dozen clients and twice that many side projects. Every tool I tried assumed somebody had already made that decision before I opened the app.
Leantime came closest and I ran on it for months. Three things pushed me off, and only one is really about Leantime.
The upsell kept landing on the thing I wanted next. The official MCP server was a paid plugin. Giving an agent its own identity instead of a shared service account needed a second paid plugin. To be fair, Leantime's agent story was real: an official server, a client bridge, three community MCP servers on GitHub. It was the gate, not the absence.
The dark theme failed contrast where it mattered. Body text was fine at 7.58:1. The accent color used for links, tabs and the primary action scored 2.98:1, 2.73:1 and 1.94:1 against the three backgrounds it sits on. All three fail WCAG AA. I measured it from the stylesheet before deciding it was a reason.
The real one: the API couldn't do what the UI could. Leantime's JSON-RPC layer was bolted on beside the application, so every feature shipped to the UI first and reached the API later or never. For a human that is an annoyance. For an agent that does most of the writing, it's the whole product.
What I built
Petrichor is a Drupal 11 module suite with a built-in MCP server. The thesis, written down before the first entity existed: a deliberately small human UI, a hardened MCP layer where every capability is a tool, and two agent-native features that are the actual product. Context bundles and persistent memory.
Custom content entities, not nodes. Projects, tasks, milestones, timesheets, wiki pages, memories. Views works on them. Field UI works on them. A site builder can add a field without touching code. There are 11 submodules laid out like Commerce, each owning one entity type, plus a theme built with Starterkit and Single Directory Components.
The MCP server came first. It's hand-rolled PHP, Streamable HTTP with JSON-RPC 2.0 at /mcp, protocol version 2025-06-18. Each submodule registers its own tools as tagged services, so a partial install exposes exactly the tools for what is enabled. There are 30 tools today. The tool list costs an agent about 3,300 tokens per session, which I measure because it's the one cost every session pays before doing anything.
One service layer. This is the rule that fixes the Leantime problem, and it's boring: every MCP tool delegates to a domain service, controllers contain no entity queries, UI endpoints call the same services. The board's drop endpoint and the update_task tool are the same code path. There is no second implementation to fall behind because there is no second implementation.
Context bundles. One call, get_project_context, returns the brief, up to 25 open tasks, 14 days of activity, the wiki index and the top 10 memories, inside a budget of roughly 3,000 tokens. Every truncated list says so: "showing 25 of 61, pass offset=25 for more." An agent silently handed page 9 is worse off than one told why.
Memory as an entity. remember, recall, forget. Scope is global or one project. Type is decision, preference, reference or learning. Each memory carries tags, the agent that wrote it, and a status. The last time a memory was recalled lives in a side table, so reading one never writes to it.
The threat model is one sentence. The system's content is also agent input. So: provenance is labeled in the response wrapper, never inside the content body. Read results are marked as data, not instructions. Agents get tiered roles, reader, contributor, maintainer. And there is no hard delete on the wire. forget archives. That guarantee lives in the entity access layer, not in the transport, because a transport check protects one door and an access check protects all of them. The first kernel test in the suite asserts that delete is denied on every entity type.
SQLite in WAL mode, streamed to Backblaze B2 by Litestream. Decided before any real data existed. A restore from B2 was tested on August 5 and again on August 13: 26 seconds, zero replication lag.
What it looks like
Every screenshot went through a Playwright sanitizer before capture. Project names, task titles, memory text and wiki bodies are generated placeholder words. Column headings, counts, dates, status badges and admin chrome are real. The live instance is where my client work lives, so this is the only honest way to show it.






The cutover
I didn't want to switch on a feeling. The exit plan, agreed on August 4, had three parts: a parity bar, a config-switch cutover, and a 30-day read-only wind-down for Leantime.
The parity bar was a Python script that runs four workflow stories against the live /mcp endpoint with an agent API key. Start a day, work a task, log time, record a decision. 16 checks. It passed on the evening of August 6.
The import read Leantime's JSON-RPC API and called Petrichor's service layer directly, not the MCP tools, so original timestamps survived. First dry run: 6 milestones, 54 tasks, 24 subtasks re-parented. A fresh re-import the same night: 0 orphans. Migrated items are tagged with their source so the history stays legible.
The cutover was the one-line edit. Leantime stayed read-only until September 5, then its container stopped.
What the numbers say
Live counts from the MCP tools on October 20:
- 56 projects, 436 tasks, 57 memories, 94 timesheets, 219.8 hours logged.
- Roughly 1,170 wiki pages. 465 came from importing three years of dated markdown project logs, 394 entries across 53 projects.
- 132 commits between August 4 and August 24. About 18,600 lines of PHP across the module suite including tests and the theme's PHP. 48 test files, 27 kernel and 21 functional.
- Config in git: 175 objects, exported and tracked since August 13.
- A 24 MB SQLite file.
Two measured facts changed the design after launch. With 54 tasks in one project, get_project_context blew past its own 3,000-token budget, which is how the 25-task cap and the explicit "showing N of M" lines got in. And the first 15 minutes of real use surfaced six problems, the sharpest being get_my_work returning an empty set while eight real tasks existed. It keyed on in-progress status plus a due-date window, and a solo user rarely sets due dates. The Today view was about to be built on that query.
What broke
August 24, the site went down. Every request timed out. Load average was 0.19. All eight PHP-FPM workers were asleep in hrtimer_nanosleep.
The MCP spec allows a server to hold a GET stream open and push notifications/tools/list_changed. I had implemented that as a 55-second loop polling a state value every 2 seconds. Each open stream pinned one worker for 55 seconds. A spec-compliant client reconnects when the stream closes, so N steady clients pin N workers permanently. Eight clients, eight workers, nothing left for the UI, the JSON-RPC POSTs or the health check.
The 22 MB write-ahead log sitting next to the 24 MB database looked like the cause. It was a consequence: checkpointing cannot complete while long-lived connections stay open. Moving to MariaDB, which I had been considering, wouldn't have fixed this. Sleeping workers sleep on any database.
The fix was a default. The stream is now opt-in, off unless a setting enables it. With it off, GET /mcp returns 405 with Allow: POST, which is what the transport spec says a POST-only server should do, so clients stop asking instead of retrying a dead endpoint. The initialize response reads the same flag, so the server can never advertise a notification it will not send. Verified live: the GET that used to hold a worker for 55 seconds returns in 0.10 seconds.
Two bugs I only found by building it. The obvious way to store drag-and-drop order is a weight field on the task. Every drag would then be an entity save, every save bumps changed, and changed is what the optimistic concurrency guard compares. Dragging a card would have silently disabled the guard that stops two agents overwriting each other. Card order went into a side table, spaced in steps of 1024.
And the guard was broken anyway. It compared RFC 3339 strings with a greater-than. That only works inside one UTC offset. Any client normalizing a timestamp to UTC, which toISOString() does by default, silently disabled its own conflict detection, and it failed open: the write proceeded. Both sides now parse to instants in one shared trait, and an unreadable token fails closed. The regression test asserts the trap before asserting the fix, so if string comparison ever stops being wrong the test fails loudly.
Where this stops being general
- One user. Me, plus agents acting as me. Nothing here has been tested with two humans disagreeing about a task.
- SQLite only, in production, on one box behind Tailscale. The modules are database-agnostic in theory. A PostgreSQL 16 test run found and fixed one portability bug, and that's the extent of it.
- No baseline for "faster." I didn't time myself on Leantime, so I can't claim the switch saved hours. The 219.8 logged hours are evidence the time logging gets used, not that it is accurate.
- The 1.0.0-alpha1 tag means what it says. The release-readiness doc lists what stands between it and a stable release.
- The drupal.org project exists and the module installs à la carte, which the test suite verifies. Nobody else has installed it that I know of.
Links: drupal.org/project/petrichor · github.com/joehahndev/petrichor · the sanitizer is lorem-gibson-generator · related: For the Drupal-curious friend