Back to devlogs
Typescript · Tools · AI

What's next: an MCP server over the training data

/ / 5 min read

The design for letting Claude search the catalogue, show a movement, write a programme and log a set. Read-only tools first, an identity problem the research did not anticipate, and why the API key trap from the migration applies inside every tool too.

A diagram of the planned MCP server: Claude on the left, a bearer-token route in the Next.js app in the middle, and the Appwrite tables and media bucket on the right.
On this page

This one is a plan rather than a shipped feature, so read it as design notes with nothing verified against a running server yet.

The idea: connect Claude to the training database. Look up an exercise, see the movement, write a programme, log a set, ask what today's session is. All the things the app does, from wherever you already are.

The design work is done and written up. What follows is what the design says and, more usefully, the two things that changed after the research was written because the app stopped being single-user.

Read-only first

The build order the research recommends, and it still holds: prove the transport with read-only tools, then add writes.

That ordering is not caution for its own sake. Transport bugs and tool-schema bugs look identical from the client, and debugging both at once while a tool is also mutating a training log is a bad afternoon. A read-only server that returns the wrong exercise is a puzzle. A write server that logs the wrong set is data you have to go and clean up.

The route is a Next handler over StreamableHTTPServerTransport, with a bearer token checked before any tool runs.

Every tool needs an identity now

The research assumed one user and an app with no login. Both are false now, and it changes the auth design rather than just adding to it.

A bearer token on its own is no longer enough, because the token has to resolve to a user. Otherwise the tools read and write somebody's sets without knowing whose.

Two ways to do it. Issue one token per user, which means token management, rotation, and a place in the UI to see and revoke them. Or resolve one token to one identity.

The plan takes the second, in its simplest form: MCP_TOKEN identifies the caller as the admin, and members do not get MCP access at all. That is honest for a personal tool, needs no token infrastructure, and can be extended later without changing any tool signature, because the tools take the resolved user as an argument from day one.

Which is the part worth doing now even though the simple version does not need it. If tools reach for an implicit current user, per-user tokens later become a rewrite of every tool. If they take a user, it stays a change to one resolver.

The API key trap applies here too

Same trap as the migration entry, in a new place.

Every read and write inside a tool goes through the Appwrite API key, which ignores row permissions. So each one has to constrain the owner itself, exactly as the app's own queries do.

This is easy to miss because the tools feel like they sit behind the app rather than beside it. They do not. They are a second, independent path to the same data, with the same bypass and none of the app's query layer unless they deliberately reuse it. The source guard that fails the build on an unconstrained query against a private table needs to cover the tool files too.

The tools

Read-only first:

  • search_exercises by split, muscle, equipment and free text, with a limit.
  • get_exercise for full detail, plus this user's history on that movement.
  • show_exercise_image.

That last one is where the interesting design decision lives. The animation is the point of the dataset, and it is also 92 KB of GIF. Sending one into a model context to answer "what does a Romanian deadlift look like" is affordable. Sending one per exercise in a twelve-movement programme is not.

So it returns a JPEG thumbnail by default, with the GIF behind an explicit animated: true, and a resource link to the live URL alongside either. Whoever is reading gets a picture cheaply and the animation when they actually ask for it. The same cap applies to next_workout, which returns text for every movement and images only on request or capped at three.

Then the writes:

  • log_set, which returns the personal-record result and any milestone crossed, so Claude can tell you that the set you just logged was a PR rather than you finding out later in the app.
  • today, the scoreboard: volume, sets, muscles hit, week streak, target progress.
  • save_program, get_program, list_programs.

save_program has one behaviour worth copying elsewhere. Programme items reference catalogue exercises by a four digit id, and a model writing a programme will occasionally invent one. When that happens the tool returns an error that names the specific bad id, rather than a generic validation failure. A model can correct a named id on the next turn. It cannot do anything useful with "invalid input".

Alongside the tools, three resources: training://today, training://program/active, and training://exercise/{exerciseId}.

Verify before wiring a client

One line in the plan that I want to hold myself to: verify every read-only tool through the MCP Inspector before connecting Claude Code, Claude Desktop or the web client.

Debugging a tool through a chat client means debugging the tool, the schema, the transport and the model's decision to call it, all at once, with a natural language interface in between. The Inspector removes three of those four.

Where this sits in the wider plan

The repository carries a build plan of fifteen files, one per feature, each a todo list with data model changes and acceptance criteria. The MCP server is number 14, and it depends on training programmes and on the data-ownership work, which is why it comes after both rather than being the fun thing built first.

The rest of the plan turns this into a small multi-user platform where friends see only their own training and the coach view sees everyone's, plus the gamification mechanics the research recommends and the UI pieces still missing. The research folder holds the reasoning: what the evidence says about streaks and badges for training apps, teardowns of what the big fitness apps actually ship, and a phased build order.

Two findings from that research shaped everything in these entries. A daily streak is the wrong mechanic for lifting, because rest is programmed. And gamification stops helping past a point, so the plan totals five mechanics and then stops, which is the harder half of the finding to actually respect.

FAQ

Frequently asked

Because transport problems and tool-schema problems look identical from the client, and debugging both while a tool is also mutating real data is far harder than debugging either alone. A read-only tool that returns the wrong record is a puzzle you solve at your own pace. A write tool that logs against the wrong account is data you then have to find and clean up. Proving the transport, the auth check and the schemas with reads first means that when a write misbehaves, the layers underneath it are already known to be sound.

A bearer token that only proves the caller is allowed in is not enough once the underlying app has more than one user, because every tool then has to know whose data to read and write. Either issue one token per user, which brings rotation and revocation with it, or resolve a single token to a single identity. For a personal tool the second is honest and needs no infrastructure. The important part either way is that tools take the resolved user as an explicit argument from the start rather than reaching for an implicit current user, so moving to per-user tokens later changes one resolver instead of every tool.

Make the expensive form opt-in. Return a small JPEG thumbnail by default, put the full animation behind an explicit flag on the tool call, and include a resource link to the live URL in both cases so the client can fetch it directly if it wants. Apply a cap wherever a tool can return many items at once: a workout listing twelve movements should return text for all of them and images only on request or limited to the first few. A single animation in a context is cheap, and twelve is not.

Because the model reading the error is the thing that has to fix it. When a tool that saves a programme rejects an exercise id the model invented, an error saying which id was wrong lets it correct that one reference on the next turn. A generic validation failure gives it nothing to act on, so it either guesses again or gives up. Returning a structured error with the offending value named turns a dead end into a self-correcting loop, which costs one extra field in the error payload.

Following along? Start a project.

Start a conversation →