Skip to main content
Glama

PoLR Workout — an MCP App for Claude

Log and review workouts from inside Claude, on desktop, web or mobile. Say "logged a 45 min Power Zone ride, 212 average watts" and get a saved workout with an editable card in the conversation. Say "starting leg day" and get a live session card with a rest timer that you tap through between sets while Claude plans the next one.

Built on the MCP Apps extension (io.modelcontextprotocol/ui, SEP-1865): a tool declares a ui:// resource, and the host renders that HTML in a sandboxed iframe inline in the chat.

It's a real app someone uses — it's also written to be read. Most of what's interesting about MCP Apps isn't the protocol, it's the decisions underneath: which tools get a view and which deliberately don't, how a card and a conversation stay in agreement about what just happened, what to do when the card scrolls out of sight. Those are all documented below and in the code.

What's worth stealing

Pattern

Where

Tools the model can't see, only the view can (visibility: ["app"]) — so browsing and filtering never fill the conversation with rows

src/server/server.ts

A write tool that renders no view, so repeated calls don't stack a card per call

log_set, suggest_next in src/server/server.ts

Pushing what's on screen back to the model with updateModelContext, so the user's edits aren't invisible to Claude

src/session/model-context.ts

Declaring outputSchema and annotations on data tools — and why putting them on a view-rendering tool can stop the app appearing at all

src/output-schema.ts, NO_SCHEMA_ON_UI_TOOLS in src/server/server.ts

Asking for pip to survive a growing conversation — and not escalating when refused

src/session/use-display-mode.ts

A timer anchored to the server so the view can remount, or open on a second device, and still be right

src/session-app.tsx

Self-contained view bundles: inline SVG charts, no CDN, because the iframe CSP blocks external hosts

src/viz.tsx

A single auth seam you can swap for OAuth without touching anything else — and a name-in-the-URL namespace scheme good enough to hand a prototype to friends

src/auth.ts

Related MCP server: Garmin-Strava-mcp

Quickstart

Needs Node ≥ 22.18 (the server runs TypeScript directly via Node's type stripping).

npm install
cp .env.example .env

# Generate a token and put it in .env as MCP_SHARED_TOKEN
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

npm run build:views          # bundle the three views into dist/
node --env-file=.env main.ts # serve on :3001

Then, in another terminal:

npm test                              # unit tests
node --env-file=.env scripts/smoke.ts # drives every tool over the wire

Your endpoint is http://localhost:3001/mcp/<token>/<your-name> — see Adding it to Claude for what the two segments mean.

To actually see the views rendered, run the reference host from the ext-apps repo (examples/basic-host, npm start) against this server with SERVERS='["http://localhost:3001/mcp/<token>/demo"]', then open http://localhost:8080. node --env-file=.env scripts/render-seed.mjs fills that demo namespace with a live session so there's something to look at. That host connects from a browser, so start the server with ALLOW_CORS=1dev only; production never needs it, because Claude calls the server directly and the views talk to it over the host's postMessage bridge rather than browser fetch.

Adding it to Claude

The server has to be reachable from the public internet — Claude connects from Anthropic's cloud, not from your machine. Put it behind a TLS-terminating reverse proxy (Caddy, nginx, Traefik, a Cloudflare Tunnel) and add your domain to ALLOWED_HOSTS.

Then: Settings → Connectors → Add custom connector → paste the full URL, which has two parts after /mcp/:

https://workouts.example.com/mcp/<token>/<name>
                                 └─┬──┘  └─┬─┘
                     gates the server      your first name — picks your namespace

So if the token is 9f3a… you'd use …/mcp/9f3a…/amit, and someone you're sharing the server with uses …/mcp/9f3a…/dana. Same server, same token, separate data. The name is folded to lowercase with punctuation collapsed, so Amit, amit and AMIT are all the same namespace and you can't lock yourself out by capitalising it differently later.

Ask Claude "which workout namespace am I in?" to check — the whoami tool answers with the namespace and how much is in it, which is the quickest way to tell a fresh install from a typo'd name.

Works on every Claude surface. The URL is the credential — treat it like a password. See Security below for what the two segments do and don't protect.

/mcp/<token> with no name still works and resolves to WORKOUTS_USER_ID (default local), so a single-user deployment that predates the name segment keeps running untouched.

Tools

Model-facing:

Tool

UI

What it does

log_workout

log view

Saves a finished workout, then renders it as an editable card

show_workouts

dashboard

History, weekly volume, PRs, trends

get_workout_stats

Plain data, so Claude can reason about progress

start_session

session view

Opens a live session with a rest timer

log_set

none, deliberately

Appends one set to the running session — reps and load, or time and distance

suggest_next

none, deliberately

Puts Claude's plan on the screen as a tappable list, strength or cardio

get_session

Reads what's on screen now: the plan in the user's order, progress, next up

end_session

Closes the session and returns a full recap

whoami

Which namespace this connector is pointed at, and how much is in it

App-only (_meta.ui.visibility: ["app"] — hidden from the model, called by the views): app_session_state, app_log_suggested_set, app_dismiss_suggestion, app_delete_set, app_reorder_plan, app_start_from_last, app_rate_session, app_save_workout, app_delete_workout, app_query_workouts, app_list_exercises, app_recent_templates. Browsing and editing go through these, so filter churn never lands in the conversation context.

Schemas and annotations, and the one place they must not go

Every tool declares an inputSchema (src/schema.ts). Most also declare an outputSchema (src/output-schema.ts) and annotations saying whether they read, write or destroy. Those earn their keep twice over: the model gets a typed contract instead of inferring the shape from one example, and the SDK validates every result before it leaves the server, so a field that quietly went undefined fails loudly rather than three turns later as a confidently wrong answer.

The three tools that render a view declare neither. A tool carrying outputSchema is announcing "my result is structured data", and at least some hosts take that at its word: the result renders as text plus a </> JSON toggle and the ui:// resource is never mounted. The tool runs, the result is valid, _meta.ui.resourceUri is right, the resource lists and reads fine — and no app appears.

That failure is silent from the server's side, which is what makes it expensive. It was found by diffing the wire format of a broken app against two that render: same SDK versions, identical initialize capabilities, identical resources/list shape, identical result envelope. outputSchema and annotations were the only structural difference across the whole protocol surface.

This is a correlation across apps on one host plus a precisely matching symptom, not a documented rule — the spec doesn't say the two are incompatible, and it may be host-specific or since fixed. It's kept because the trade is lopsided: the keys buy a typed contract on three tools, and being wrong costs the entire reason the project exists. scripts/smoke.ts asserts the invariant both ways so a tidy-up can't quietly reintroduce it.

The cost is losing SDK-side validation on those three, softened by the app-only tools returning the same shapes with schemas — app_save_workout mirrors log_workout, app_query_workouts mirrors show_workouts, app_session_state mirrors start_session — so drift still fails loudly somewhere.

Two other things that catch people out:

  • A declared outputSchema makes structuredContent mandatory on any non-error result. isError: true skips validation, but a success that returns only text does not. That's why get_session answers "no session is running" with the empty state rather than a bare line of text.

  • The schemas are annotated with the domain types they mirror (z.ZodType<PlanEntry>), so drifting from src/types.ts is a compile error rather than a runtime surprise. That check still covers the three UI tools, which is what keeps their payloads honest without a declared schema.

Live sessions

start_session opens a card with a rest timer for logging set by set while training.

log_set has no UI resource on purpose. A tool that declares a view renders a fresh card on every call, so eight sets would stack eight cards in the conversation. Instead the one open session card polls app_session_state every 2s, which means both paths land in the same place:

  • Dictate to Claude — "225 for 5" → log_set → the open card picks it up. This is the reliable hands-free path: it works on every surface, needs no iframe permission, and leans on the host's own dictation.

  • Tap in the view — steppers and a per-row "Log set" button, 48px targets for use with chalk on your hands.

  • In-app voice — the session resource requests permissions: { microphone: {} } and uses the Web Speech API, parsing phrases locally via src/parse-set.ts ("225 for 5", "bench 185 by 8", "two twenty five for five"). This is progressive enhancement only — the API is absent in some engines (notably Electron builds without a speech backend), so the button is feature-detected and simply doesn't appear when it's unavailable. Nothing depends on it.

Talking is the interface. The card is built around the conversation, not a form:

  • One list. The plan and the work are the same rows: each exercise shows its target, the sets you've logged against it, and whether it's done. Two lists meant a half-finished exercise appeared twice and you had to read both to know where you were. Rows keep a stable order — suggested exercises in Claude's order, then anything logged off-plan — so nothing moves out from under your thumb mid-set.

  • Off-plan work joins the list, tagged "added". Do something Claude didn't suggest and it gets its own row with its sets, rather than being invisible.

  • Progress is derived from the sets, never counted separately. A dictated set advances the plan exactly like a tap does. A stored counter only moved when you tapped, so a set told to Claude — the main way sets get logged — left a row reading "0 of 3" beside the very set that satisfied it. Warmups land on the row but don't count toward a target. A revision needs no special handling either: progress lives in the sets, which a revision doesn't touch.

  • Removing a suggestion (the × on a planned row) drops it from the plan and keeps any sets already logged under that exercise — the row simply becomes off-plan work.

  • Drag the ⠿ handle to reorder. Implemented with pointer events rather than HTML5 drag-and-drop, which never fires on touch — and a phone propped against a rack is the main way this gets used. The handle sets touch-action: none, without which the browser claims the gesture for scrolling and no pointermove ever arrives. Arrow keys on the handle move a row too, so reordering isn't drag-only. Polling is suspended mid-drag: otherwise a refresh two seconds in replaces the order being dragged with the server's. The order persists in its own plan_order table keyed by exercise — not on suggestions.position, because off-plan rows have no suggestion and still need to be draggable. Rows you place stay put; anything arriving later (a new suggestion, or a set logged for something new) appends in arrival order, so nothing jumps above what's already on screen.

  • suggest_next carries no UI, same reason as log_set: a plan that spawned its own card would stack a new one every time Claude changed its mind.

  • Log something else by hand — the exercise field, steppers, voice button and rest target live behind a disclosure. They're the fallback, not the main event.

Phone layout. The plan row's four-column desktop grid doesn't degrade at phone width, it explodes: with no room each column wraps onto its own line, so the handle floats centred above the name, the button goes full-bleed and the remove × ends up orphaned beneath it. Below 520px the cells are placed explicitly instead — handle left, name and × on the first line, target and set chips beneath, action button bottom-right — with fixed outer track widths and width: 100% on the row. auto tracks collapsed to 0px and, because the row was sizing to its content rather than filling the card, 1fr behaved like max-content and every row resolved different columns, stepping the names raggedly rightward.

Note the class named .manualEntry: the manual-entry form and the plan row both used to be called .entry, so an old @media (max-width: 460px) rule written for the form silently rewrote the plan row's grid to a single column. Two components must not share a class name.

Opening and closing. Both ends of a session used to be blank. Now:

  • Openingstart_session looks up the last comparable session (same title, falling back to same type) and returns its exercises with top sets. The card shows them with a one-tap "Start from last time" (app_start_from_last copies them into the plan), and the tool text tells Claude to call suggest_next with a concrete plan rather than asking what you're doing. An empty card wastes the moment.

  • Closingend_session returns a full recap: per-exercise numbers plus records broken, judged against everything logged before this session (so a session can't beat itself). The card shows a completion screen — volume as the hero figure, tiles, any PRs, per-exercise breakdown, and an RPE/notes capture via app_rate_session — instead of the one grey line that made it read as "the app just closed". It also no longer drops out of pip on finish, which was making the card vanish at the exact moment it had something to say.

An app can't make Claude speak — sendMessage only stages a draft in the composer — so the card carries the closing experience itself, and the recap goes into model context so that asking "how did that go?" gets an informed answer rather than a re-derived one.

Keeping the model in step. Nothing the user does in the card reaches Claude on its own — dragging rows, removing an exercise, tapping a set. Left alone, Claude keeps answering "what's next" from the plan it proposed and names the wrong exercise. Two things fix that:

  • get_session lets Claude read the current plan, in the user's order, with progress and an explicit "NEXT" marker. Its description says plainly that the order from suggest_next is not authoritative afterwards.

  • The view pushes a short summary through updateModelContext after every user-initiated change (debounced 600ms), so Claude is told even when it doesn't think to look. Polls deliberately don't trigger this — only actions the user took.

Every updateModelContext payload in all three views uses YAML frontmatter followed by prose, including one-line events, so the model never has to guess whether a block is structured.

Staying visible. Every dictated set adds a turn, which pushes the card up the conversation, and the protocol has no "scroll to me". So when a live session opens, the view requests the pip display mode — a floating card that stays put — and switches to a compact layout (timer, what's next, one tap to log it, Finish) sized for a small container. Whether pip is granted is up to the host: if it isn't offered the card stays inline and shows a one-tap "Go fullscreen" instead. It never escalates to fullscreen on its own — taking over the conversation uninvited is worse than a card that scrolled.

In fullscreen the view drops its container radius (.main.fullscreen { border-radius: 0 }) so content reaches the viewport edges instead of leaving rounded gutters. Both the dashboard and the session view do this.

Treat the granted mode from requestDisplayMode as authoritative rather than the one you asked for: a host may grant something different, and isn't obliged to follow up with a host-context change.

The rest timer is anchored to the server: elapsed time is derived from the stored logged_at of the last set, never from a client-side counter. So the view can remount, or open on a second device, and still show the right number. Polling pauses via IntersectionObserver when the card scrolls offscreen and stops when the session ends.

An in-progress session is excluded from history and stats until end_session closes it — it has no duration yet and would skew every rollup.

Cardio

A set is reps and load, or time and distance, or both — durationSec and distanceM sit on every set alongside reps and weightLb, and all four are nullable. "Rowed 2k in 8:30" is distanceM: 2000, durationSec: 510; "10 minutes on the bike" is durationSec: 600 on its own.

They went onto the existing sets table rather than a parallel cardio one because a set is still a set: it has a position in the session, a logged_at that anchors the rest timer, and an exercise. A second table would have doubled every read path for one differing pair of columns.

Distance is stored in metres, always. Rowers and tracks are metric, the treadmill here is not, and picking one storage unit means the pace maths never has to ask which. The views convert on the way out — under 2 km reads as metres and /500m, above it as miles and /mi, from one threshold so a distance and its pace can't disagree about scale. The model-facing text in src/server/server.ts deliberately does not share that rule: it uses metres and min/km throughout, because Claude reads those strings to reason with and one fixed unit beats an idiomatic one that changes shape with distance.

exercises.modality (strength | cardio) says how an exercise is normally measured, which is what makes the session card offer a distance and a clock instead of a weight field. It's a default, not a constraint — the columns are on every set, so a timed plank or a weighted carry records whatever fits without being reclassified.

What cardio does to the numbers

Volume is reps × weight, so a cardio set contributes nothing to it — correctly, but it means a conditioning session has a real distanceM and a volumeLb of zero. Every header treats that as a different kind of session rather than an empty one and shows only the figures with something behind them; "0 lb" never appears.

Records split the same way. ExercisePr stays strength-only and a parallel CardioPr carries farthest distance, longest time and best pace, so no existing reader had to start handling nulls it can never see. Two things are easy to get wrong here and are worth knowing about:

  • Pace runs backwards. It's stored as seconds per km, so a record is a smaller number than the one it beat. prBeats in src/types.ts is the only place that comparison lives, and prAgainst is the only place that decides between "up from" and "down from" — an earlier shape of this said "up from" for everything and reported a faster 2k as a regression.

  • A pace record needs a floor. Without one a ten-metre burst sets an untouchable pace and every real effort after it looks like a decline, so only sets of at least 400 m can claim one.

Dictation is unit-driven, never magnitude-driven: parseSpokenSet reads "500 metres" as a distance and a bare "500" as a load. Guessing from the number would file a heavy single as a rowing record, and the test for it is there to keep it that way.

Layout

The tree splits on the runtime boundary: src/server runs in Node, src/views runs in the host's sandboxed iframe, and exactly one module is shared between them.

main.ts                     HTTP entry — /mcp/:token (stateless Streamable HTTP), /healthz

src/types.ts                The one shared module: domain types both sides import

src/server/                 Node only — never bundled into a view
  server.ts                   Tool + UI-resource registration
  auth.ts                     The auth seam — swap this for OAuth, nothing else changes
  db.ts                       SQLite connection + the schema, declared in one place
  queries.ts                  All reads and writes; rollups in JS for correct local-time weeks
  schema.ts                   Zod shapes for tool input, plus date normalization
  output-schema.ts            Zod shapes for tool output, checked against src/types.ts
  tz.ts                       The one place WORKOUTS_TZ is read

src/views/                  Browser only — bundled by Vite into dist/
  use-mcp-app.ts              The host-connection shell all three views share
  log-app.tsx                 Log/edit view
  dashboard-app.tsx           Dashboard view
  session-app.tsx             Live session view — state and server calls
  session/                    its screens and hooks:
    plan-list.tsx               the one merged list of plan + logged work
    compact-card.tsx            the pinned (pip) layout
    summary-screen.tsx          the closing screen
    opening-screen.tsx          "start from last time"
    rest-ring.tsx               the timer ring
    use-plan-drag.ts            pointer-event reordering
    use-display-mode.ts         pip/fullscreen negotiation
    use-speech-set.ts           Web Speech API lifecycle
    model-context.ts            what the card tells the model about itself
  parse-set.ts                Spoken phrase -> set; DOM-free so it's directly testable
  viz.tsx                     Inline-SVG chart primitives (no chart library — CSP)
  format.ts                   Display formatting for the views (viewer's locale)
  insets.ts                   Safe-area insets as CSS custom properties

scripts/smoke.ts            End-to-end: drives every tool over Streamable HTTP
scripts/render-seed.mjs     Seeds a live session so you can look at the view

The split is enforced, not just suggested. npm run typecheck runs two configs rather than one: tsconfig.server.json drops the DOM and DOM.Iterable libs, so a document or localStorage reference that drifts into server code is a compile error instead of a crash at request time; tsconfig.views.json keeps them. Together they cover every file, and src/types.ts is checked by both — the one shared module has to compile with and without the DOM to stay shared. The root tsconfig.json still spans everything, for the editor and Vite.

The runtime image follows the same line: the Dockerfile ships src/server and src/types.ts only, because the view sources exist to be bundled and are already baked into dist/.

Development

npm run typecheck   # tsc --noEmit
npm test            # unit tests: the spoken-set parser, date normalization
npm run smoke       # end-to-end against a running server
npm run build       # typecheck + bundle all three views
npm run dev         # watch the log view and restart the server on change

npm test covers the pure logic that's easy to get subtly wrong and hard to notice: the spoken-set parser, timezone-aware date handling, and the name → namespace mapping. scripts/smoke.ts covers everything else by driving the real server over the wire — 52 assertions about tool behaviour, including that two namespaces genuinely can't see each other's workouts or exercises, and that no view-rendering tool has picked up the keys that stop it rendering. Because every tool declares an outputSchema, running it also validates every response shape.

It works in its own smoke-test namespace and deletes the workouts it creates, so it's safe to point at a server with real data on it — set MCP_ORIGIN to run it against a deployment, which is the only way to catch host-facing regressions that don't reproduce locally. It does leave a few learned exercise rows behind in its own namespaces, since there's no tool to unlearn one; they're invisible to every other namespace.

.env is gitignored; .env.example documents every variable. Local dev defaults to ./data/dev.db; the container uses /data/workouts.db on a mounted volume.

Deployment

cp .env.example .env    # fill in MCP_SHARED_TOKEN, ALLOWED_HOSTS, WORKOUTS_TZ
docker compose up -d --build

The container listens on 3001, bound to loopback. Point your reverse proxy at it and put the public hostname in ALLOWED_HOSTS — the server binds 0.0.0.0 so the proxy can reach it, and the Host allowlist is what stops it answering requests for someone else's hostname (DNS-rebinding protection). The database lives on the ./data volume.

Verify with the health endpoint, which needs no token:

curl -s https://workouts.example.com/healthz -w '\n%{http_code}\n'

Security: what this scheme does, and doesn't, do

The endpoint is /mcp/<token>/<name> — a capability URL with a namespace on the end. It's this way because Claude on mobile can't attach custom headers, so everything has to live in the URL the connector is configured with.

The two segments do different jobs, and it's worth being blunt about both:

<token> gates the server. It's a shared secret, compared in constant time. A wrong token gets the same bare 404 as a wrong path, so the endpoint isn't discoverable, and the token is never written to the log — the access log prints /mcp/<token>/amit literally, keeping the namespace (useful) and dropping the secret (not).

<name> separates data, not people. Every row is scoped by the namespace derived from it, so two testers with the same token genuinely don't see each other's workouts — not their history, not their live session, not even the exercise names they've invented. But anyone holding the token can reach any namespace by typing a different name. There is no per-person secret.

So: this is enough to let a handful of people try the thing without tripping over each other's data. It is not enough to keep them out of each other's data if they go looking, and it's not a substitute for real auth. Give the token to people you'd be relaxed about, and don't log anything you'd mind one of them reading.

Also worth knowing:

  • The subdomain has to be public, not behind an SSO proxy: Claude connects from Anthropic's cloud and can't complete an interactive login.

  • Names are folded to a key (Mary Janemary-jane) built only from letters and digits. That key is a SQL parameter and never touches the filesystem, and the display name shown to the model is rebuilt from the key — so a namespace can't be named something that reads as an instruction.

  • Namespaces are created implicitly. Typing a name nobody has used gets you an empty one, which is the intended way to onboard and also the most likely way to be confused; ask Claude to run whoami if a namespace looks emptier than expected.

Moving to real auth is a change in one file: replace authenticate in src/auth.ts with OAuth token verification and derive userId from the JWT sub, still returning the same AuthContext. Nothing downstream reads the token or the raw name, and user_id is already on every row, so there's no data migration.

Notes on the design

  • Stateless transport. sessionIdGenerator: undefined, a fresh McpServer per request. The standalone GET stream answers 405 rather than opening an event stream that can never carry a message — left to the transport, a client waiting on it hangs instead of failing.

  • user_id on every row, including the exercises dictionary. That was the last table without one, and it made exercise names the one thing that crossed between namespaces — someone else's invented lift turning up in your autocomplete. NULL now means "built-in, shared by everyone" and anything learned belongs to whoever learned it. Carrying user_id from day one is also what makes moving to OAuth a change in one file rather than a migration.

  • The schema is declared, not migrated. src/server/db.ts states the tables as they are now and creates them with IF NOT EXISTS, so opening an existing database is a no-op. Before 1.0, with the data on one box, that reads better than a replay of six diffs — you can answer "what columns does sets have" by looking. The cost is explicit and worth saying out loud: changing a table here does not change a database that already has data in it. A new column means recreating the file or writing the ALTER by hand, and the moment the schema has to move under data worth keeping, this is the thing to replace with a real migration runner. Seeding is the exception — the built-in exercises go in with INSERT OR IGNORE on every open, so adding one to the list does reach an existing database.

  • Rollups in JS, not SQL. Week and day boundaries have to respect WORKOUTS_TZ; SQLite's date functions only know UTC and localtime-of-the-host. Everything the model is told about time is formatted in that zone too, so Claude never reports an evening session as 2am.

  • PRs are derived, never stored — max weight and best Epley 1RM per exercise, computed from non-warmup sets.

  • Charts are hand-rolled inline SVG. The iframe CSP blocks external hosts and each view ships as one self-contained HTML resource. Views are bundled through preact/compat to keep each bundle around 390 kB instead of 550 kB — that's download cost on every device, including phones.

  • Every chart plots one measure, so each is a single hue with no legend, a hover tooltip, and a table view. Colors are the validated blue accent (#2a78d6 light, #3987e5 dark), which clears contrast against Claude's light and dark surfaces.

Not included

Peloton or Strava auto-import. The source column on workouts leaves room for it.

Per-set heart rate, watts or calories. A cardio set records time and distance only — workouts already carries avgOutputW and calories for the session as a whole, which is the granularity a Peloton class reports at anyway.

License

MIT — see LICENSE.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • F
    license
    A
    quality
    B
    maintenance
    Personal workout coach MCP server that logs exercises in natural language, tracks progress with SQLite, and provides coaching signals like estimated 1RM and volume trends.
    6
  • A
    license
    -
    quality
    B
    maintenance
    MCP server for the Hevy workout tracker that enables users to query training history, log workouts, and manage routines via natural language.
    MIT

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pathofleastresistor/polr-mcp-workout'

If you have feedback or need assistance with the MCP directory API, please join our Discord server