Skip to content
Tommy CardeLo
Go back

Gestorio — An in-house PMO with an MCP server so Claude Code can pick up tasks

Gestorio is the internal PMO of 2DATO: projects, tasks, notes, weekly client updates and a client portal. An MCP server lets Claude Code read tasks and move them through their states, and any write that a client will see goes through a preview plus a confirmation code that the agent can never approve by itself.


The problem

2DATO is the automation and AI-agents project I run with two friends. Three people, several client projects, and the usual mess: tasks in chat messages, decisions in meeting notes, and a weekly “how is it going?” that nobody wanted to write. Jira or Linear would have been overkill (no sprints, no Scrum, no hierarchy: all three of us are admins), and none of them know about our notes vault or can talk to an AI coding agent on our terms.

So I built a small PMO that fits how we work, and then gave it an MCP interface, because most of the code for those projects is written with Claude Code, and the agent should know what is pending without me pasting it into the prompt.

What it does

Architecture

  Claude Code / claude.ai ──(MCP over HTTP, per-person token)──┐
                                                               ▼
 Browser (admins) ──► Next.js 15 (App Router) ──► Postgres (pg, SQL migrations)
 Browser (clients) ─► /estado portal (own session) ──┘            ▲
                                                                  │
 systemd timers + webhooks (Node scripts) ── Anthropic API ───────┤
   · weekly draft  · notes → tasks  · task briefs  · health check │
 WhatsApp group ─► small FastAPI bot ─► webhook ──────────────────┘

It all runs on a single VPS with Docker Compose (Postgres, the app, and a reverse proxy with automatic TLS). Postgres listens only on the loopback interface. CI deploys on every push to main: rsync, run migrations, rebuild.

Key design decisions

1. No ORM, four tables to start

pg directly plus numbered .sql migrations. The schema started with projects, tasks, weekly_updates and digests and grew from there (milestones, clients, comments, drafts). With a schema this small, an ORM is one more thing to debug and nothing I would miss.

2. Two kinds of users, two separate auth worlds

Admins log in with Google against an exact allowlist, re-checked in the middleware on every request. Clients never touch that system: they have their own table, their own HMAC-signed session cookie and a scrypt password hash. A session carries a session_version that is compared against the database on each load, so revoking access or resetting a password kills sessions that are already open. A reviewer caught that one before it shipped: my first version only checked at login.

The client portal only does a SELECT of explicit columns. The internal summary and blockers of a weekly update never reach it; clients only see client_summary, which is written or approved by a human. I took the AI out of that path on purpose, because internal blockers tend to leak into whatever a model summarizes.

3. MCP: few tools, one cheap write, the rest gated

The server is a stateless /api/mcp route. It exposes eleven tools:

Moving a task is reversible and internal, so it is a plain call. Anything a client will read is different.

4. Two-step confirmation that the agent cannot fake

Each client-visible tool, called without confirmar, saves nothing. It returns a preview and a codigo_confirmacion. That code is an HMAC over exactly the data shown, including the “before” state for edits, signed with a key generated at process start:

const CLAVE_CONFIRMACION = randomBytes(32); // lives only in this process

const codigo = createHmac("sha256", CLAVE_CONFIRMACION)
  .update(JSON.stringify({ herramienta, datos }))
  .digest("hex");
if (confirmar === codigo) return null; // proceed to write

To save, the agent must call again with the same data and that code. Different data gives a different code, so the agent cannot show one thing and save another, and if the record changed between preview and confirmation the code no longer matches. Restarting the app invalidates every pending code. The server instructions tell the agent to show the preview verbatim and confirm only after the user explicitly approves in that moment. Honestly, this is a protocol, not a lock: a model could still call the tool twice in a row. The HMAC guarantees consistency, the instructions and the human in the loop provide the consent. I preferred that over inventing an approval channel outside the conversation.

5. Tokens in the path, because connectors have no headers

Claude Code sends a Bearer header. The custom connectors in claude.ai only accept a URL. So each person has their own token, accepted either as a header or as a path segment, compared with a SHA-256 plus timingSafeEqual. Per-person tokens are revocable one at a time, and the log records who changed which task. OAuth would be cleaner and is the next step if distributing tokens gets annoying.

6. The “develop tasks” workflow lives in the repo

Each repo declares its project in its own CLAUDE.md with a single line (Proyecto Gestorio: #<id>). The MCP server’s instructions describe the loop:

  1. Read the project id from the repo’s CLAUDE.md.
  2. listar_tareas filtered to TODO; for each, ver_tarea and use the brief as the spec.
  3. Move it to IN_PROGRESS before starting.
  4. Mark it DONE only after build and tests are green. If it cannot be finished, BLOCKED, with the reason explained to me.

So “develop the tasks” is the whole prompt. The server owns the workflow, the repo only says which project it is.

7. Notes without copying them

Notes are read straight from the mounted vault filesystem: no copy in Postgres. That brought a real security finding: a symlink pushed into the vault could make the reader follow it outside the vault, to the process environment and its secrets. The note reader now resolves real paths and refuses symlinks while walking folders. A related decision: the notes agent only creates tasks from new notes, never edits, so a rewritten note does not duplicate work.

8. AI scripts, isolated and loud

Every AI job (weekly drafts, notes to tasks, briefs, WhatsApp parsing) is a small Node script with no tools, structured JSON output, and no direct database write: the script parses and inserts. They started on a Claude Code subscription and moved to the API with prepaid credits, because sharing quota with my interactive sessions meant a job could fail silently the week I used Claude Code heavily. A health-check job now probes the AI every hour and emails when it breaks, and tells apart a missing key, no credits and an overloaded API.

9. The WhatsApp bridge

A small FastAPI service sits behind the team group. Messages must start with a fixed phrase to be processed; everything else is ignored silently. Voice notes are transcribed with Whisper, biased with a prompt toward the trigger phrases because it kept mangling the first words. The bot only answers commands; it never starts a conversation.

What I learned

Stack

Next.js 15 (App Router) · TypeScript · Tailwind 4 · shadcn/ui · next-auth · PostgreSQL (pg, SQL migrations) · MCP SDK + zod · Anthropic API · FastAPI (WhatsApp bridge) · Docker Compose · systemd timers · GitHub Actions · Playwright


Share this post on:

Previous Post
Kana Practice — Learning to read hiragana by phonetically adapting Spanish
Next Post
Weekly shift scheduling with OR-Tools CP-SAT — one hard rule, three soft ones, and Excel as the database