Skip to content
MCP, From Scratch

The Frontend

Phase 4TypeBuildLanguageTypeScript · ReactTime~12 min readPrereqThe Backend

Three posts in, TaskFlow is a database, an API, and a set of rules — and nobody has clicked anything. This post is the React app. It exists for two reasons that have nothing to do with React: to prove the backend is a real product and not a fixture for an MCP server, and to give you something familiar to compare the chat client against later. It gets one post and does not get to consume more than that.

Five screens

The design doc lists five, and the app has five. Every one of them is one file in src/pages/, and every one calls the API through the same typed functions. Here is the flow a person walks, with Alice — admin of both seeded projects — driving.

The TaskFlow login form: email, password, a Log in button, and a Register link
Screen 1. Login and register share a page. A wrong password shows the backend’s exact sentence — “Invalid email or password” — and stays here. That sounds obvious; it took one bug to make it true.
Alice's projects page: two project cards (API and WEB, both admin), an “Assigned to me” list of seven tasks across both projects, and a New project form
Screen 2. Projects Alice belongs to — the join from last post, rendered — plus everything assigned to her across both, from GET /me/tasks. That list exists so the chat client’s my_tasks tool has a UI to be compared against. Overdue dates are red because the seed’s dates are in July.
The WEB board: four status columns (To do 3, In progress 2, In review 1, Done 2) with task cards, each card carrying a status select; filters “Only mine” and “Only overdue” and a Members link top right; a New task form below
Screen 3. The board. Each card’s status select is a one-field PATCH. The two checkboxes send assignee_id and overdue to GET /projects/WEB/tasks rather than filtering in the browser — the MCP list_tasks tool will use the same two parameters, so the two clients can never disagree about what “overdue” means.
The same board with “Only overdue” ticked: To do 1, In progress 2, In review 1, Done 0
“Only overdue” ticked. Four of eight shown, and the Done column is empty — a finished task is never overdue, however late it was. That rule lives in the backend’s query, not here.
Task detail for WEB-9 “Walkthrough smoke task”: a form with title, description, status, priority, assignee (Bob Brown), due date; a disabled Save button and a Delete task button; a Comments panel on the right with one comment
Screen 4. One task. Save is disabled because nothing has changed — the form is diffed against the loaded task on every keystroke. Delete task is visible because Alice is an admin; Bob would not see it, and would get a 403 if he found the route anyway.
The WEB members page: a red banner reading “Cannot remove the last admin of project 'WEB'. Add another admin first.”, two members (Alice admin, Bob member) each with a Remove button, and an Add a member form
Screen 5, after Alice clicked Remove on herself. The banner is the backend’s 409 body, shown verbatim. The frontend did not know that rule; it just displayed the answer.

One fetch

Every request the app makes goes through one function in . The pages call typed wrappers — one per route in the design doc — and never touch fetch. That is what keeps “how we talk to the API” in one place when it inevitably changes.

frontend/src/api.ts
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
  const headers: Record<string, string> = {};
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (token !== null) headers["Authorization"] = `Bearer ${token}`;

  const response = await fetch(API_URL + path, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  // DELETE routes answer 204 with no body; `response.json()` would throw.
  if (response.status === 204) return undefined as T;

  const data: unknown = await response.json().catch(() => null);

  if (!response.ok) {
    // Only a 401 on a request that *had* a token means "your session is
    // over". A 401 from POST /auth/login with a wrong password is a
    // different thing — it's the answer to the question, not a sign that
    // anything expired — and must not bounce the user around.
    if (response.status === 401 && token !== null) {
      setToken(null);
      unauthorizedHandler();
    }
    throw new ApiError(response.status, errorMessage(response.status, data));
  }
  return data as T;
}
  • The token rides along on every call. Authorization: Bearer …, exactly the header last post’s current_user reads. It is held in a module-level variable and mirrored to localStorage so a reload doesn’t log you out. Anyone who can run JavaScript on the origin can read it — that is the standard objection, and the file says so in a comment rather than hiding it. The alternative, an httpOnly cookie, needs CSRF protection and a different CORS setup, which is a post of its own.
  • A 401 is two different things. From /auth/login it means “wrong password”. From anything else it means “your token is dead”. The first draft bounced the user to the login page on both — which, on the login page, meant the error flashed and vanished. One condition fixes it: only a 401 on a request that carried a token ends the session.
  • Two error shapes become one sentence. FastAPI answers 401/403/404/409 with {"detail": "a sentence"} and 422 with a list of what was wrong. errorMessage flattens either into something a banner can show. Because the backend wrote its sentences to be read by a person, the frontend never composes an error message of its own.
  • The types are snake_case on purpose. mirrors the Pydantic schemas field for field and keeps full_name and my_role. The repo convention is camelCase for TypeScript identifiers, but these describe JSON we don’t own. A rename layer between the reader and what they see in the network tab is exactly where “works in curl, not in the app” bugs live.

PATCH sends the diff

Last post spent a section on the backend telling apart “left out” from “sent null”. The task form in is where that distinction earns its keep:

frontend/src/pages/TaskPage.tsx
/** The PATCH body: only what changed. Empty strings become null. */
function diff(task: Task, draft: Draft): TaskPatch {
  const patch: TaskPatch = {};
  if (draft.title !== task.title) patch.title = draft.title;
  if ((draft.description || null) !== task.description) patch.description = draft.description || null;
  if (draft.status !== task.status) patch.status = draft.status;
  if (draft.priority !== task.priority) patch.priority = draft.priority;
  if ((draft.assignee_id || null) !== (task.assignee?.id ?? null)) {
    patch.assignee_id = draft.assignee_id || null;
  }
  if ((draft.due_date || null) !== task.due_date) patch.due_date = draft.due_date || null;
  return patch;
}
  • Form fields are strings; the API wants null for “none”. An empty assignee select is "". draft.assignee_id || null turns it into null, which the backend reads as “unassign”. A field the user didn’t touch is never added to patch at all.
  • JSON.stringify already speaks PATCH. A key whose value is undefined vanishes from the output; a key whose value is null survives. So a TaskPatch with optional-and-nullable fields maps onto the wire with no code at all. Sending the whole form would also have worked — and would have hidden the one thing the backend’s PATCH was designed around.
  • The same function drives the Save button. dirty is “does diff() return anything?”, recomputed on every render. Nothing changed, nothing to send, Save disabled — that is the greyed-out button in the screenshot above.

On WEB-9, Alice clears the assignee select (to “Unassigned”) and changes nothing else, then clicks Save. What body does the browser send?

Thirty lines instead of a library

Four pages need the same thing: fetch on mount, show the error, refetch after a write. That is one hook, , and it is the “no React Query” decision in code. The part worth reading is the flag:

frontend/src/hooks.ts
useEffect(() => {
  // Why the flag: React 19 in dev (StrictMode) mounts, unmounts and mounts
  // every component once to surface effects that don't clean up. Without
  // this, the first (thrown-away) mount's response could land after the
  // second's and overwrite fresh data with stale. The same race happens
  // for real when the user clicks from WEB to API before WEB has loaded.
  let cancelled = false;
  setState((previous) => ({ ...previous, loading: true, error: null }));
  load().then(
    (data) => {
      if (!cancelled) setState({ data, error: null, loading: false });
    },
    (error: unknown) => {
      if (!cancelled) setState({ data: undefined, error: describeError(error), loading: false });
    },
  );
  return () => {
    cancelled = true;
  };
}, [...deps, generation]);

StrictMode forces you to write this, and then it turns out to be the same guard you need when a user navigates faster than the network answers. generation is a counter; bumping it is how reload() re-runs the effect after a save. React Router — the one dependency beyond React — is used in plain <BrowserRouter> mode with no loaders, so it stays a route table.

Hide, don’t enforce

Admin controls — the Members link, the Delete task button, the add/remove member form — are shown only to admins. Nothing in the frontend prevents anything. From :

frontend/src/pages/MembersPage.tsx
// The list is visible to any member (GET .../members is member-only in the
// backend); adding and removing are admin-only. The form and the remove
// buttons are shown only to admins. That is a courtesy for the reader of
// the page, not the rule — the rule is `require_admin` in app/deps.py, and
// phase 8's tool gating will follow exactly this shape: hide, but never rely
// on hiding.

const isAdmin = project.data?.my_role === "admin";
// ...
{isAdmin && (
  <button type="button" onClick={() => removeMember(m)}>Remove</button>
)}
The WEB members page as Bob: the same two members, no Remove buttons, and instead of the add form the text “Only an admin of this project can add or remove members.”
The same page as Bob, a member. No Remove buttons, no form. He got here by typing the URL — the Members link isn’t on his board. If he crafted a DELETE anyway, the backend’s require_admin would answer 403 and the banner would show the sentence.

isAdmin comes from my_role on the project response — the caller’s row in project_members, read fresh on every load. Promote Bob and his next page load shows the form. The route guard in App.tsx says the same thing about itself in a comment: anyone can delete this component and see the pages — but every page would then get 401s from the backend and show nothing. The backend is the rule; the guard is the good manners. Keep that sentence. It is the shape of the MCP tool gating, four posts from now.

One .env, and a prefix that does the security

Every service in TaskFlow reads the same .env at the repo root. Vite would normally look for one next to , so two lines point it up a level:

frontend/vite.config.ts
const repoRoot = fileURLToPath(new URL("..", import.meta.url));

export default defineConfig(({ mode }) => {
  // "" as the prefix filter: give me every variable, because FRONTEND_PORT
  // does not start with VITE_. This is config-side, not browser-side.
  const env = loadEnv(mode, repoRoot, "");

  return {
    plugins: [react(), tailwindcss()],
    // Where the *browser* bundle gets its variables from. Vite only exposes
    // variables prefixed VITE_ to browser code, so the database password in
    // the same file can never leak into the bundle. That prefix is the whole
    // safety mechanism; keep it in mind before naming anything VITE_.
    envDir: repoRoot,
    server: {
      port: Number(env.FRONTEND_PORT ?? 5173),
      // If 5173 is taken, fail instead of silently picking 5174 — the
      // backend's CORS list names 5173 exactly.
      strictPort: true,
    },
  };
});

The same file holds POSTGRES_PASSWORD and JWT_SECRET. The only thing keeping them out of a JavaScript bundle anyone can download is that their names don’t start with VITE_. Worth knowing before you name anything.

The “done when” is a script

The design doc’s bar for this phase is can do the whole flow with a mouse. The repo’s rule is no manual clicking that isn’t also a script. So drives a real headless Chrome through every screen, against the running backend and the seed, and checks what a person would check. Nineteen checks; every screenshot in this post came out of it.

npm run walkthrough
  ok  wrong password: 401 sentence shown, still on /login
  ok  projects: WEB listed
  ok  projects: API listed
  ok  board: WEB-4 moved in_progress → in_review via PATCH
  ok  board: "Only overdue" filter asked the API (4 of 8 shown)
  ok  board: task created (201) and appears in To do
  ok  task WEB-9: Save disabled while nothing changed
  ok  task WEB-9: assignee + priority saved, form refilled from the response
  ok  task WEB-9: comment posted and listed
  ok  task WEB-9: deleted (204), back on the board without it
  ok  members: removing the last admin shows the 409 sentence
  ok  members: Carol added (201)
  ok  members: Carol removed (204)
  ok  logout: token gone from localStorage
  ok  bob: no Members link on the board
  ok  bob: members page by URL is read-only, no form
  ok  bob: project he's not in shows the backend's 403 sentence
  ok  bad token: 401 on /auth/me → login page, token cleared
  ok  no console errors

PASS — screenshots in ../scratch/screenshots/
  • Zero dependencies. Node 22+ ships fetch and a WebSocket client, and Chrome speaks the DevTools Protocol over a WebSocket. Everything in the script is “send a JSON command, wait for the reply”. Playwright would have hidden exactly the part worth seeing.
  • It leaves the database as it found it. Creates then deletes; adds then removes; moves a card then moves it back. You can run it twice.
  • It is not in npm run build, and it is not pytest. It needs Chrome and two running servers. The rules are tested where they live, in fastapi-backend/tests; this proves the UI wires the routes together. Honest, and enough for one post.

What went wrong

Vite built a bundle with a type error in it

the symptom
$ npx tsc --noEmit
error TS2345: Argument of type 'TaskFilters' is not assignable to parameter of type
  'Record<string, string | boolean | undefined>'. Index signature for type 'string' is missing.

$ npx vite build      # same tree
✓ built in 310ms

Two things. Vite transpiles with esbuild, which strips type annotations and never checks them, so a successful vite build says nothing about types. And the error itself: TypeScript infers an implicit index signature for object type aliases but refuses to for interfaces, because an interface can be extended later with a property of any type. TaskFilters was an interface. It became a type, with a comment, and "build" is now tsc --noEmit && vite build so this class of bug can’t ship. The Vite docs mention the no-type-checking on the TypeScript features page — not next to vite build. The interface-versus-type rule is in a 2017 GitHub issue, not the handbook.

The walkthrough’s first draft used nth-of-type on selects

timed out waiting for selector form select:nth-of-type(3), while the page visibly had four selects. :nth-of-type counts siblings within one parent, and each select sits inside its own <label>, so every select is the first of its type. A script bug, not an app bug, and it cost a run. The fix indexes into document.querySelectorAll("form select") and gives the board’s selects an aria-label so a test can name them — which also makes them readable to a screen reader, the right reason to have it. MDN does say this about :nth-of-type. We didn’t read it.

Said plainly: what this frontend does not do

  • No tests in the pytest sense. The walkthrough is the proof, and it won’t run in CI as-is.
  • The token is in localStorage. See the comment in api.ts.
  • No pagination, no search, no drag-and-drop. Four columns and a select. No role change — promote by removing and re-adding as admin. window.confirm for destructive actions; a modal would teach nothing.
  • The seed’s dates are fixed, so “overdue” drifts. Every due date is in July 2026, so by the time you run this, every dated task is red. Cosmetic.

Run it

The backend from last post must be up on :8000 with the seed loaded. Then, from the Phase 4 commit:

bash
git clone https://github.com/Jayantkhandebharad/mcp-taskflow.git
cd mcp-taskflow
git checkout 4ce4dff

# terminal 1 — the database and the API, exactly as last post
cp .env.example .env
docker compose up -d db
cd fastapi-backend && uv sync && uv run alembic upgrade head && uv run python -m scripts.seed
uv run uvicorn app.main:app --reload --port 8000

# terminal 2 — the frontend
cd frontend
npm install                 # from package-lock.json
npm run dev                 # http://localhost:5173

# optional, terminal 3 — the scripted click-through (needs Chrome)
cd frontend && npm run walkthrough

Log in as alice@example.com / password. Then log out and in again as bob@example.com, and notice what is missing from his board. Then type /projects/API into the address bar as Bob and read the banner. That banner is last post, seen from the other side.

What’s next

The app is finished. Database, rules, API, a face — and not one line of it knows what MCP is. Which means the part this series exists for can finally start: next post is your first MCP server. It will log in the same way this frontend does, hold the same kind of token, and call the same /auth/me and /projects you just watched the browser call — and Claude Desktop will use it. The frontend and the MCP server are the same kind of client. If that sentence already feels obvious, this post did its job.

  • One fetch. The token goes on every call; a 401 only ends the session if the call carried one.
  • PATCH the diff. "" becomes null, untouched becomes absent, and JSON.stringify does the rest.
  • Hide, but never rely on hiding. my_role decides what’s shown; require_admin decides what’s allowed.
  • Only VITE_* reaches the browser. That prefix is the whole wall between your secrets and your bundle.
  • vite build does not type-check. Put tsc --noEmit in front of it.
  • The done-when is a script. Chrome’s DevTools Protocol is JSON over a WebSocket; you don’t need a framework to click.
series progress0%
Code & notebooks for this series