I open my fitness tracker every day. It pulls workouts from Peloton and Tonal, tracks annual goals, makes pretty charts. Until this week, the way I interacted with it was: open browser, click button, look at chart. Like a 2018 web app.
This week I made it an MCP server. Now I ask Discord "what was my last workout?" and carrybot — my homelab OpenClaw bot, running on my Linux homelab PC, talking to a local Qwen3.5-35B on llama.cpp — answers with real data from the same Postgres my browser hits. Same endpoint also works from Claude Desktop, Codex, Cursor, and any Coder workspace agent that knows how to call it.
This is the writeup of the afternoon that took me there. The MCP server itself was easy. The interesting parts were the constraints I bumped into and the workarounds that turned out to be cleaner than the "right" answer.
The Goal
One sentence: let any AI agent talk to my fitness data.
The vibe coded fitness tracker is a single-user Next.js 14 app on Vercel. Gated to one Google account. REST endpoints behind a NextAuth session cookie. Peloton and Tonal sync triggered by clicking buttons in the dashboard. That works for the browser. It doesn't work for an agent that wants to ask "summarize my training over the last quarter" or "trigger a Peloton sync — did anything new come in?"
I want the agent to have raw access. No precomputed summaries. Give it the rows and let it figure out the trends. Part of the point is to learn how agents get better at this kind of analysis over time, and that doesn't happen if I do the math for them.
Why MCP, Not OpenAPI
I almost shipped this as an OpenAPI spec plus bearer-token auth. Cleaner, simpler, every agent framework supports it.
Then I listed the clients I actually want to use:
| Client | OpenAPI | MCP |
|---|---|---|
| Claude Desktop | Custom integration | Native |
| Codex CLI | Custom integration | Native |
| Coder Agents | Via AI Bridge | Via AI Bridge |
| OpenClaw | Via plugin | Native |
| Cursor, Windsurf, Zed | Custom | Native |
Every client speaks MCP first-class. Ship MCP, write the tools once, every agent picks them up by pointing at a URL. Ship OpenAPI and every client needs bespoke wiring. The decision was over before I finished the table.
The Server
Three files, ~400 lines total.
src/app/api/mcp/[transport]/route.ts — the MCP route, built on mcp-handler (the package formerly known as @vercel/mcp-adapter before it got renamed and republished). Ten tools:
server.tool('list_workouts', /* schema */, async ({...}) => {...})
server.tool('get_workout', /* schema */, async ({id}) => {...})
server.tool('create_workout', /* schema */, async ({...}) => {...})
server.tool('update_workout', /* schema */, async ({...}) => {...})
server.tool('delete_workout', /* schema */, async ({id}) => {...})
server.tool('list_goals', /* schema */, async () => {...})
server.tool('peloton_status', /* schema */, async () => {...})
server.tool('sync_peloton', /* schema */, async ({limit})=> {...})
server.tool('tonal_status', /* schema */, async () => {...})
server.tool('sync_tonal', /* schema */, async ({limit})=> {...})
The CRUD tools wrap Prisma directly. The sync tools fetch() the existing REST endpoints (/api/peloton/sync, /api/tonal/sync) so I'm not duplicating the dedup orchestration — those endpoints already handle "did we already sync this workout? does this row need backfilling? did the Peloton token expire?" Wrapping them is one HTTP hop. Worth it to keep one source of truth for sync logic.
src/lib/api-auth.ts — bearer token helpers. The token is a single env var, MCP_API_TOKEN, 64 random hex chars. Compared in constant time so I don't leak timing side channels:
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let mismatch = 0
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i)
}
return mismatch === 0
}
middleware.ts — extended so the bearer token unlocks every /api/* route, not just /api/mcp. Same token, two callers: the MCP server calls Prisma directly for read tools, and self-fetches the existing REST routes for the sync tools. Both paths need to pass auth. The token does double duty.
The transport choice was the one decision worth thinking about. mcp-handler supports SSE and streamable HTTP. SSE needs Redis for message brokering. Streamable HTTP is stateless. I'm on Vercel Hobby with no Redis. disableSse: true and ship.
{ basePath: '/api/mcp', verboseLogs: false, maxDuration: 300, disableSse: true }
pnpm i mcp-handler @modelcontextprotocol/sdk@1.26.0 zod — and yes, you have to pin the SDK to 1.26.0 because mcp-handler@1.1.0 peer-depends on exactly that version, not a semver range. Half an hour of npm install errors before I noticed.
The Test That Said It Worked
curl -sS -X POST https://<actualapp>.vercel.app/api/mcp/mcp \
-H "Authorization: Bearer $MCP_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Response: 200 OK, event: message, full tool catalog with JSON Schemas. The server worked.
The hard part wasn't the server. It was getting the four clients I cared about to use it.
Client #1: Claude Desktop, Codex, Cursor — The Easy Path
These all read a JSON config file with the same shape:
{
"mcpServers": {
"fitness-tracker": {
"type": "http",
"url": "https://robs-fitness-tracker.vercel.app/api/mcp/mcp",
"headers": {
"Authorization": "Bearer <MCP_API_TOKEN>"
}
}
}
}
Drop in the URL, drop in the token, restart the client. Done.
Client #2: Coder Workspace Agents — The Path I Got Wrong
I run Coder on my workstation. Every workspace gets a ~/.mcp.json baked in by the Terraform template (Context7, Vercel, Cloudflare, Playwright — see the homelab post). My mental model: add a fifth entry for fitness-tracker, the agent picks it up.
So I patched the template. Token flows from ~/.config/fitness-tracker/env on the workstation → TF_VAR_fitness_tracker_mcp_token in /etc/coder.d/coder.env → Terraform variable → coder_agent.main.env → workspace process → jq-merge into ~/.mcp.json at startup with chmod 600. One PR, one apply.sh, every workspace gets it.
Verified the file showed up in a fresh workspace with all five MCP servers in the keys. Confidently asked the agent: "list my fitness-tracker tools."
"I don't have any fitness-tracker tools available. My available tools are for software-engineering tasks inside a Coder workspace..."
The agent had no idea. Started a fresh chat — same answer. Inspected the agent runtime and found this in Coder's source at v2.33.2:
// enterprise/aibridgedserver/aibridgedserver.go
for _, link := range links {
if link.ProviderID != eac.ID { continue }
valid, _, validateErr := eac.ValidateToken(ctx, link.OAuthToken())
// ...
tokens[id] = link.OAuthAccessToken
}
Coder's AI Bridge only auto-registers OAuth-backed MCP servers. Specifically, MCP servers wired through CODER_EXTERNAL_AUTH_*_MCP_URL against an OAuth external auth provider. Static-token MCP servers are invisible to the chat agent. The ~/.mcp.json file is for other MCP clients running in the workspace (Claude Desktop, Codex, code-server's Continue extension), not for Coder's chat itself.
I'd shipped a coder-templates PR that does the right thing for every MCP client except the one I was trying to enable. The PR is still useful — it makes the fitness tracker available to any MCP client a workspace user wires up. But Coder Agents specifically were locked out.
Two real options:
- Wrap the fitness tracker in OAuth. NextAuth supports being an OAuth provider. Register it in Coder as an external auth. Coder mints tokens, AI Bridge injects them. Significant work for a single-user app.
-
Teach the agent the recipe. Write a skill file that documents the endpoint, the auth, the wire shape, and the ten tools. Agent reads the skill at chat start and calls the MCP server with
curl.
Option 2 was 200 lines of Markdown. I picked option 2.
---
name: fitness-tracker
description: "Access the personal fitness-tracker MCP server via raw HTTP..."
---
## Call recipe
ft_call() {
local tool="$1" args="${2:-{\}}"
local payload=$(jq -cn --arg t "$tool" --argjson a "$args" \
'{jsonrpc:"2.0", id:1, method:"tools/call",
params:{name:$t, arguments:$a}}')
curl -sS -X POST https://robs-fitness-tracker.vercel.app/api/mcp/mcp \
-H "Authorization: Bearer $FITNESS_TRACKER_MCP_TOKEN" \
... | sed -n 's/^data: //p' | jq -r '.result.content[0].text // .error'
}
ft_call list_workouts '{"year":2025,"limit":10}'
That ships in the same template patch as the ~/.mcp.json entry. Per-user, gated to my account. Next fresh chat: agent reads the skill, sees the recipe, calls the server. Works on the first try.
Client #4: OpenClaw — The Client I Built This For
The whole reason I started this afternoon. carrybot runs on the homelab and accessed from any Discord client. Local Qwen3.5-35B for the brain, Discord for the front end, OpenClaw as the agent runtime.
I DM'd carrybot and asked how to register an MCP server. He came back with:
"I need a bit more info about your fitness app MCP endpoint:
Transport type: Is it HTTP(S), SSE, streamable-http, or stdio?
URL: What's the endpoint URL?
Auth: You mentioned a token — is it in headers or URL?
Tool names: What specific tools does it expose?"
Pasted the answers. He ran:
openclaw mcp set fitness-app '{
"type": "streamable-http",
"url": "https://robs-fitness-tracker.vercel.app/api/mcp/mcp",
"headers": { "Authorization": "Bearer ${FITNESS_TRACKER_MCP_TOKEN}" }
}'
Asked: "What was my last workout?"
He replied with a 30-minute Power Zone Pop Ride with Ben Alldis on May 12 at 4:12 PM PDT. 7.98 miles. Real Peloton data, pulled through the MCP server I'd built three hours earlier, into a Discord bot running in the other room.
The Cron Ghost
One amusing wrinkle. Carrybot's first response included an inline alert:
⚠️ Cron:
fitness-checkfailed
I asked him what that was. He came back with full transparency:
"You're right to ask! That 'fitness-check' cron never actually got created — I made a typo when trying to add it. ... The API returned:
invalid cron.add params: at root: unexpected property 'sessionTarget":'. That's because I accidentally added an extra quote:"sessionTarget": ": "main"should have been"sessionTarget": "main". The JSON was malformed."
He'd tried to use OpenClaw's cron system as a deferred-execution primitive — schedule a one-shot task for one minute from now, then have the cron run the MCP query. Malformed JSON, cron creation failed, OpenClaw faithfully alerted me about the failure, and he answered my question via a sub-agent path instead. The data I saw was real. The "cron failed" was background noise from a bug he caught and fixed in his next message.
I gave him the UX feedback — one-shot crons as deferred execution mean every malformed payload looks like a production alert — and he immediately revised the pattern:
"Sub-agent direct call → Use for one-off work. Cleaner, no cron overhead.
Cron for real scheduling → Only useatcrons when you actually need deferred execution.
Don't spam alerts → Malformed JSON that prevents a cron from being created shouldn't generate a scary 'Cron failed' alert."
That's a long-running agent learning its own UX patterns. Worth its own post someday.
Token Storage
One token, four locations, all mode 600 or equivalent. Same value everywhere:
1. Vercel project env var MCP_API_TOKEN
2. Workstation ~/.config/fitness-tracker/env (chmod 600)
3. Coder server /etc/coder.d/coder.env (root-readable systemd EnvironmentFile)
4. Coder workspaces ~/.mcp.json (chmod 600, regenerated per workspace start)
5. OpenClaw ~/.openclaw/openclaw.json (chmod 600)
Rotation: openssl rand -hex 32, update all five locations, redeploy Vercel. Roughly 90 seconds, no code changes.
The token lives in env vars, never in shell rc files. The shell-rc anti-pattern is real — anything exported into ~/.bashrc leaks into every subshell's process listing, gets sourced by background jobs that shouldn't see it, and survives in .bash_history for as long as that file lives. A chmod 600 env file you source explicitly when you need it stays in exactly the processes that need it.
What I'd Do Differently
Verify the agent runtime's MCP integration before patching templates. I patched coder-templates to add a workspace-level ~/.mcp.json entry before I'd checked whether Coder's chat agent actually reads that file. It doesn't. The patch is still useful for other MCP clients running in the workspace, but I wouldn't have prioritized it first if I'd known.
Skip the OpenAPI consideration earlier. I spent real cycles writing the "MCP vs OpenAPI" comparison in my head. The clients I cared about all speak MCP natively. The decision was over before I started thinking about it; I just didn't realize it for ten minutes.
Start with the skill file as a first-class option, not a workaround. When I hit the Coder AI Bridge limitation, my first instinct was "build OAuth, ship the proper integration." The skill file approach is genuinely simpler, lives next to existing skills, and will be obsolete the day AI Bridge gains static-token support — which seems like a planned-but-not-yet-shipped feature based on the deprecation comments in Coder's source. Skill files are the right level of investment when the underlying platform is in flux.
What's Next
-
Test the skill in a fresh Coder chat. The PR merged but I haven't validated it end-to-end yet. The skill is concrete enough that the agent should call
ft_call list_workoutson the first try. If it fumbles, the skill needs tightening. -
Watch the raw-rows decision over time. All ten tools return raw database rows. Zero precomputed aggregates. The whole point is to see whether agents naturally synthesize good summaries or degrade as the dataset grows. If they degrade, add a
summarize_yeartool. Until then, keep the surface area small. -
Token rotation drill. I haven't had to rotate
MCP_API_TOKENyet. Worth doing once intentionally to find any place we forgot to document. -
Wait for AI Bridge to support static-token MCP servers. When it does, the skill file becomes redundant and the
~/.mcp.jsonentry becomes the canonical path. Until then, the skill is the working path.
The fitness tracker is now genuinely agent-accessible. Same vibe coded app that started as a Next.js weekend project, now serving four different agent runtimes through a single MCP endpoint. The audit a few weeks ago found the bugs. This week added the API surface. Next steps are about watching agents use it.
The lobster's a real assistant now.
By the Numbers
- 3 hours total session time
- 2 GitHub PRs opened and merged (fitness-tracker, coder-templates)
- 1 follow-up PR for the skill file workaround
- 10 MCP tools exposed, all returning raw rows
- 0 precomputed aggregates — agents do their own analysis
- 4 client integrations working from one endpoint (Claude Desktop, Codex / Cursor / etc., Coder Agents via skill, OpenClaw)
- 1 dead-end — Coder AI Bridge's OAuth-only MCP injection requirement
- 200 lines of Markdown in the skill that workaround it
- 64 hex chars in the personal access token
- 5 locations that hold the token, all mode 600 or equivalent
- 1 ghost cron that alerted me to a bug in carrybot's own code
- 1 long-running agent that revised its own UX patterns based on feedback
- 30 minutes — the duration of the last workout the bot reported
- 7.98 miles — distance on that Power Zone Pop Ride with Ben Alldis
Top comments (0)