MCP · Full build · 01 of 1
The Setup: What We’re Building
In the last series we took an AI assistant and connected it to Moodle — a big, existing system we didn’t write. That taught the protocol, but a lot of the work was fighting someone else’s twenty-year-old codebase. This series does the opposite. We build a small application from nothing, understand every line of it, and then add an AI interface on top. The app is the boring part on purpose, so all your attention is free for the interesting part: MCP.
What we’re building
The app is called TaskFlow. It’s a task tracker — think Trello with the fun removed. It is deliberately small, because every feature has to earn its place by teaching something about MCP, not about task management.
- You sign up and create a project. You become its admin.
- Admins add other people to a project as admin or member.
- Members create tasks, assign them, move them through statuses, and comment on them.
- Only admins can delete a task or manage who’s in the project.
That’s the entire product. What makes it worth writing about is the second half of the sentence at the top: then give it an AI interface, without changing a line of the app. By the end you’ll be able to type “what should I work on next?” or “move WEB-14 to in progress” into a chat box and watch a language model do it — safely, as you, with exactly your permissions.
The four services
TaskFlow is four separate programs, each in its own container, each with one job. The discipline that keeps the project clean is simple: if you can’t say a service’s job in one sentence, it’s doing too much.
| Service | Port | Its one job |
|---|---|---|
| fastapi-backend | 8000 | Own the data and enforce the rules. Knows nothing about MCP or AI. |
| frontend | 5173 | Let a human use the app with a mouse. Knows nothing about MCP or AI. |
| mcp-server | 9000 | Turn the backend’s API into MCP tools an AI can call. Stores nothing. |
| chat-client | 8100 | Let a human use the app by typing English. Runs the agent loop. |
Notice that the first two don’t know the last two exist. You could delete the MCP server and the chat client, and TaskFlow would still be a working web app.
The architecture
Here is how the four services and the database fit together. Two humans, two ways in: one using the website with a mouse, one talking to the AI. Follow the arrows.
Read the left column first — it’s an ordinary web app. The browser calls the frontend, the frontend calls the backend, the backend talks to Postgres. Nothing surprising.
The right column is the same picture with the AI slotted in. The browser talks to the chat-client, which runs a language model. When the model wants to do something, it calls a tool on the mcp-server — and here’s the decision the entire series rests on:
Two things fall out of that, and they’re the whole reason to build it this way:
- The rules are written once. “Only admins delete tasks” lives in one place — the backend. If the MCP server queried Postgres directly, that rule would have to exist twice, and the two copies would drift. When they drift, the AI path is the one nobody’s watching.
- The AI is not a superuser. Trace the two green labels in the diagram. The user’s token rides from the chat client, through the MCP server, to the backend — never swapped for a more powerful one. If Alice can’t see project WEB, neither can Alice’s AI assistant. There’s no service account to steal and no “trust me, I’m the AI” path into the data.
It costs an extra network hop, and we’ll measure that honestly rather than pretend the clean design is free. The full reasoning — including what we rejected and why — is written up as an architecture decision record: . And a request traced step-by-step through both paths lives in .
// question
Alice is a member (not an admin) of project WEB. She asks the AI to delete a task. What stops it?
The folder structure
One repository, one folder per service, plus a docs folder for the writing. This is the entire tree at the point this post is written — the four service folders each hold just a README for now, because we haven’t written any code yet. That’s the honest starting line.
mcp-taskflow/
├── README.md # what it is, how to run it, the diagram above
├── PLAN.md # the full design: data model, auth, every tool
├── CLAUDE.md # how we work on this repo
├── docker-compose.yml # one command boots everything
├── .env.example # every config value, documented
├── LICENSE
│
├── fastapi-backend/ # owns the data. the only thing that touches the DB.
│ └── README.md
├── mcp-server/ # the MCP layer. owns no data.
│ └── README.md
├── chat-client/ # the MCP client. talks to the LLM.
│ └── README.md
├── frontend/ # the normal web app. no MCP anywhere.
│ └── README.md
│
└── docs/
├── architecture.md # one request, traced through both paths
├── ideas.md # the parking lot for features we're NOT building
├── decisions/ # ADRs — why we chose what we chose
└── briefs/ # one file per phase; the blog posts grow from theseEach service folder is one container, one dependency file, one job. As the series progresses, they fill in — the backend grows an app/ with models, routers, and migrations; the MCP server grows its tools; the chat client grows its agent. But the shape stays exactly this. Every folder’s README already states, in one sentence, what it’s for and — more usefully — what it must never do. Here’s the backend’s, for example: .
One command boots the world
Everything runs through Docker Compose. Right now there’s exactly one service in it — PostgreSQL — because that’s all Phase 0 needs. Every other service gets added to this same file as its phase lands.
name: taskflow
services:
db:
image: postgres:16-alpine
container_name: taskflow-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
# A named volume, so data survives `docker compose down`.
# To wipe it and start clean: docker compose down -v
- db_data:/var/lib/postgresql/data
healthcheck:
# Postgres briefly accepts connections during startup, then restarts.
# A service that connects in that window gets a confusing error. So later
# services wait on this, not on "the container started".
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
volumes:
db_data:Two small choices in there are worth calling out, because both save a beginner an hour of confusion:
- The healthcheck. A lot of Compose examples use a bare
depends_on, which only waits for a container to start. Postgres accepts connections for a moment during initialisation, then restarts — so a backend that connects in that window fails with an error that looks random. Thepg_isreadyhealthcheck lets later services wait until the database is genuinely ready. It’s the single most common cause of “it works on the seconddocker compose up.” - A named volume, not a bind mount.
db_datakeeps the database files inside Docker rather than in your working tree, which avoids file-permission headaches on macOS and keeps the repo clean. When you want a fresh start,docker compose down -vwipes it.
All the values with ${...} come from a .env file you copy from — every variable the whole project will ever read, grouped by service, with a comment on each. One value there is shared on purpose: JWT_SECRET is used by both the backend (to sign tokens) and the MCP server (to verify them). That’s why the MCP server can reject a forged token instantly — and it’s the kind of thing we flag loudly rather than leave for you to discover.
Bring your own model
One more thing that’s part of “what we’re building,” because it shapes the chat client: TaskFlow is not tied to any one AI vendor. Exactly one file in the whole repo will know which model provider we use. Switching between Claude, GPT, Gemini, or a model running locally on your laptop is one line in .env:
LLM_MODEL=anthropic:claude-opus-4-8
# LLM_MODEL=openai:gpt-4.1
# LLM_MODEL=google_genai:gemini-2.0-flash
# LLM_MODEL=ollama:qwen2.5:14b # fully local, no API key, no internetThat matters here for a reason beyond convenience: MCP’s whole premise is that any model can call any tool, so a demo of MCP shouldn’t be welded to one provider — and you shouldn’t need our API key to follow along. There’s an honest catch we’ll dig into much later (independence is not the same as equivalence — small local models are often bad at tool calling), but that’s a problem for a future post. For now, just know the door is open.
Clone it and follow from exactly here
Everything is public. If you want to build alongside the series, start from the exact commit this post describes — the skeleton, before any application code — so your starting line matches mine:
git clone https://github.com/Jayantkhandebharad/mcp-taskflow.git
cd mcp-taskflow
# start from the Phase 0 skeleton this post is written against
git checkout 13f27aa
# boot the database
cp .env.example .env
docker compose up
# in another terminal — is it alive?
docker compose ps
docker compose exec db psql -U taskflow -d taskflow -c '\dt'That last command lists the tables and — correctly — shows none yet. An empty database is exactly the right result for Phase 0.
What’s next
We have the map, the skeleton, and a running database. Next post we build the foundation everything sits on: the data model — five tables, real migrations, and a seed script that fills an empty database with believable demo data you can run over and over. That’s where TaskFlow stops being a diagram and starts being a thing.
- We’re building TaskFlow: a small task tracker, then an AI interface on top of it.
- Four services, four containers, one
docker compose up— and the backend won’t know MCP exists. - The load-bearing decision: the MCP server calls the API, not the database, and forwards the user’s own token.
- Clone from commit
13f27aato follow along from the exact starting line.