PoLR Workout
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@PoLR Workoutlogged a 45 min Power Zone ride, 212 average watts"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 ( | |
A write tool that renders no view, so repeated calls don't stack a card per call |
|
Pushing what's on screen back to the model with | |
Declaring | src/output-schema.ts, |
Asking for | |
A timer anchored to the server so the view can remount, or open on a second device, and still be right | |
Self-contained view bundles: inline SVG charts, no CDN, because the iframe CSP blocks external hosts | |
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 |
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 :3001Then, in another terminal:
npm test # unit tests
node --env-file=.env scripts/smoke.ts # drives every tool over the wireYour 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=1 — dev
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 namespaceSo 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 view | Saves a finished workout, then renders it as an editable card |
| dashboard | History, weekly volume, PRs, trends |
| — | Plain data, so Claude can reason about progress |
| session view | Opens a live session with a rest timer |
| none, deliberately | Appends one set to the running session — reps and load, or time and distance |
| none, deliberately | Puts Claude's plan on the screen as a tappable list, strength or cardio |
| — | Reads what's on screen now: the plan in the user's order, progress, next up |
| — | Closes the session and returns a full recap |
| — | 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
outputSchemamakesstructuredContentmandatory on any non-error result.isError: trueskips validation, but a success that returns only text does not. That's whyget_sessionanswers "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 nopointermoveever 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 ownplan_ordertable keyed by exercise — not onsuggestions.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_nextcarries no UI, same reason aslog_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:
Opening —
start_sessionlooks 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_lastcopies them into the plan), and the tool text tells Claude to callsuggest_nextwith a concrete plan rather than asking what you're doing. An empty card wastes the moment.Closing —
end_sessionreturns 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 viaapp_rate_session— instead of the one grey line that made it read as "the app just closed". It also no longer drops out ofpipon 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_sessionlets Claude read the current plan, in the user's order, with progress and an explicit "NEXT" marker. Its description says plainly that the order fromsuggest_nextis not authoritative afterwards.The view pushes a short summary through
updateModelContextafter 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.
prBeatsin src/types.ts is the only place that comparison lives, andprAgainstis 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 viewThe 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 changenpm 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 --buildThe 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 Jane→mary-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
whoamiif 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 freshMcpServerper request. The standalone GET stream answers405rather 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_idon every row, including theexercisesdictionary. 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.NULLnow means "built-in, shared by everyone" and anything learned belongs to whoever learned it. Carryinguser_idfrom 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 doessetshave" 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 theALTERby 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 withINSERT OR IGNOREon 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/compatto 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 (
#2a78d6light,#3987e5dark), 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.
This server cannot be installed
Maintenance
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
- Flicense-qualityBmaintenanceMCP server for tracking nutrition meals and workouts, integrating with claude.ai to manage food logs, macros, exercise catalogs, and generate daily/weekly summaries.
- Flicense-qualityBmaintenanceMCP server that gives Claude access to real-time Garmin Connect and Strava data for analyzing training and suggesting sessions.
- FlicenseAqualityBmaintenancePersonal 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
- Alicense-qualityBmaintenanceMCP server for the Hevy workout tracker that enables users to query training history, log workouts, and manage routines via natural language.MIT
Related MCP Connectors
Augments MCP Server - A comprehensive framework documentation provider for Claude Code
Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer
MCP server for AI job search — find jobs, track applications, get alerts. Claude, ChatGPT, Cursor.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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