MCP server
A remote Model Context Protocol endpoint that lets external AI clients — Claude, ChatGPT, Cursor, anything that speaks MCP — act on a Taikan studio as the signed-in staff member.
What
POST /mcp on the API is a stateless Streamable HTTP MCP server. It is not a second agent: it is a protocol adapter over the Spotter tool layer. Every tool it lists is a Spotter leaf (@AgentTool), and every call runs through ToolRunnerService — the same role check, Zod validation, tier feature gates and audit writes Spotter uses.
┌── Spotter (Anthropic router-tools)
Taikan domain services ← Tool Registry / Runner
└── MCP adapter ──► Claude / ChatGPT / Cursor / …Who
Staff only — owner, admin, coach with an active membership in the chosen org. Members are refused, exactly as in Spotter. The tool list is the caller’s Spotter tool list for their role minus the tools below that MCP cannot serve, so an MCP client can never act beyond the same user’s permissions.
Behavior
Tools
- Each Spotter leaf
router.actionbecomes one flat MCP tool namedrouter_action(e.g.read.members_search→read_members_search,tasks.create→tasks_create). No router multiplexing. inputSchemais the leaf’s Zod schema as JSON Schema (input side: defaulted fields are optional).annotations.readOnlyHintmirrorssideEffects: 'read'.- Not exposed:
- tools with
confirm: 'destructive' | 'always'— Spotter holds these behind an in-app confirmation card, and MCP has no confirmation channel every client honours. Do them in the Taikan app. user_pickertools (read.ask_user_to_pick) — the MCP client asks its own user instead.
- tools with
- A leaf may carry
mcp: { summary }: served over MCP with that summary as its description. Spotter keeps the shared summary; its day-grain guidance lives in its system prompt. - Summaries are written once with Spotter’s dotted names (
workouts.parse); MCP rewrites a reference to a known router to the name a client can call (workouts_parse). - Calling a hidden or out-of-role tool returns the same
unknown_toolerror as a tool that does not exist. - Tool errors come back as
isError: truewith{ error: { code, message, hint?, details? } }— the runner’s error shape (invalid_input,forbidden,not_found,conflict,tool_error).
Day-grain programming
Every org programs at day grain, over MCP and in Spotter alike: both hold workouts.parse and workouts.upsert, and Spotter’s system prompt teaches the day-grain flow below (see Spotter behavior).
| Behavior | |
|---|---|
| Server instructions | One coach day (or class) is ONE workout per program and date. Its parts (“Warm-up”, “Lift”, “Conditioning”, “Optional Successory”) are section parts; a part holds one or more blocks (sections). Day brief → workout description; a part’s note, clock window and optional flag → the part; block notes → section description; sourceText = the coach’s verbatim block text minus demo links moved onto movements: a link that shows one movement → that movement’s mediaUrl (the same link for that movement in every workout → exercises_set_media) and its line left out of sourceText; a link stays in sourceText only when it belongs to no single movement; score slots only where the coach wrote one. Coach text goes verbatim to workouts_parse with the day’s date and save; suggested movements are confirmed with save.resolutions in the call that saves. Every workout has a title — the coach’s, the one parse proposes, or the date-and-program name upsert gives a date — never the brief. Every warning is read: fixed and re-sent, or (auto_confirmed_movement) told to the coach. Publish once per day; several workouts on a date only for genuinely separate sessions. |
workouts_upsert, workouts_parse | Listed and callable over MCP and in Spotter. |
| Spotter (in-app) | The static prompt plus a day-grain section (DAY_GRAIN_PROMPT in context-builder.service.ts): one workout per day with parts, brief → description, coach text always through workouts.parse, suggestions confirmed with save.resolutions, always a title, every warning read back. Spotter never sends sourceText/scores (refused off MCP); the coach’s words reach a workout through workouts.parse. |
workouts_create, workouts_set_sections, programs_session_create descriptions | MCP serves day-grain wording (mcp.summary); Spotter keeps the shared summary. |
| MCP section writes | workouts_set_sections and workouts_upsert run SectionFidelityService.fill first: omitted score slots and movement marks are derived from sourceText, an unscored workout takes the scoring the text implies, and the result carries warnings (missing_prescription, percent_base_mismatch, text_only_section, invented_score_slot). A write is never refused for a warning. Spotter’s workouts.set_sections is stored as sent. |
workouts_upsertwrites one day in one call: workout fields (title,description= the brief,coachNote,sessionType,scoring?,timeCap,programId), an optionaldate(YYYY-MM-DD), a requiredexternalRef(e.g.<program>:<date>), andparts: [{ key, title, note?, optional?, clockStartSeconds?, clockEndSeconds?, sections }]and/or flatsections(blocks outside any part, placed first). At least one and at most 20 sections; part keys unique. Sections are numbered by position and carry their part; the workout is always structured (needsworkout_builder). It goes throughWorkoutsService.create, which updates the workout an existingexternalRefnames and replaces its sections; a re-send clears thedescription,coachNote,sessionTypeandtimeCapit leaves out, while an omittedprogramIdkeeps the current program. A write withouttitleis named bydayWorkoutTitle(libs/shared/src/lib/workout-title.ts) —dailyWorkoutTitle(date, program name)whendateis sent, else the part titles joined ” · ” — on create and on theexternalRefupdate alike, so the brief never becomes the name lists show. Returns{ id, created, updated, title, sectionCount, partCount, movementCount, scoring, warnings }. Audited asworkout.upsert. In Spotter, a section carryingsourceTextorscoresis refused (Source text and score slots require the MCP channel) and nothing is written.workouts_parseruns the coach’s text for one day through the paste parser (see workout parse) and returns a proposal in theworkouts_upsertinput shape. Input:text(verbatim),date?(YYYY-MM-DD),save?: { externalRef?, programId?, title?, resolutions?: [{ movementKey, mention, exerciseId }] }. Output:{ jobId, workout, parts, sections, unresolved, warnings, saved, workoutId?, notSavedReasons? }.workout.titleis always set:save.title, else a title the text itself states (never the brief, a part or block heading, or a blank/“null” answer), elsedailyWorkoutTitle(date, program name)whendateis known (the program fromsave.programId), else the part titles joined ” · ”.unresolved[]— every movement not matched with confidence:{ partKey, sectionIndex (within its part; workout-wide when partKey is null), workoutSectionIndex, movementKey, line, mention, status, candidates: [{ exerciseId, name, score }], prescription }, whereprescriptionis what the parser read off the line, so confirming the movement keeps its numbers.savewrites only when every movement matched and nothing in the text needs review; otherwisesaved: falseandnotSavedReasons. To confirm suggestions and save in one call, re-send the same text andsavewithresolutionstaken fromunresolved(eachexerciseIdone of that movement’s candidates or another exercise the org can use). A resolution matches bymention— keys can shift when the text is parsed again — and covers every movement with that mention; one that matches no movement, or names an exercise the org cannot use, saves nothing and says why innotSavedReasons.warningscarries the fidelity warnings above plusauto_confirmed_movement(movements: [{ movementKey, mention, exerciseId, name }]): suggestions a cheap model confirmed from the line and its block, for the coach to review.- A parse runs a model and takes tens of seconds (12–48s seen in production); clients need a tool-call timeout well above that (the sync script defaults to 180s).
- A line that is only an
https://link never stops a save; it stays in the block’ssourceText, tappable there, and becomes one movement’smediaUrl(in the proposal and on the saved workout) when the block says which: the*-marked movement of a footnote the link sits in or next to, else the movement on the line right above it, else the block’s only movement. “Pick one:” lists and “if you don’t have a sled → sub with 15 tibialis raises” lines are that movement’salternatives(the options the library names exactly), never extra movements. Rules: workout parse behavior.
- Prescriptions the day-grain instructions ask for: calories by sex (
reps.kind: 'calories',bySex), implement height, scalinglevels,percent_referenceloads with a namedreference(neverpercent_1rmoff a 3RM), EMOMintervalSecondsMaxfor “Every 3:00-3:30”,group_loadfor a complex, and movementalternativesfor “machine of choice”.
Movement media
Demo media is org-scoped (see exercises → effective movement media). Every media URL is an absolute https:// URL of at most 2048 characters.
| Tool | Behavior |
|---|---|
exercises_set_media | { exerciseId, mediaUrl: string | null }, staff only, audited (exercise.set_media). A library id creates or reuses the org’s fork (ExerciseOverridesService.upsertOverride) — the library row is never written; an org custom or fork id is updated in place. null clears back to the library video. Another org’s exercise is not_found. Returns { exerciseId, media }. |
exercises_create_custom | Optional mediaUrl sets the new exercise’s video; on an existing same-name org exercise it replaces that row’s video. |
workouts_set_sections, workouts_upsert | Optional mediaUrl per movement: media for that occurrence only, stored on workout_movements.media_url, kept through releases and snapshots. |
read_exercises_search items, read_exercises_resolve_batch candidates | media: { url, kind: 'video' | 'web', source: 'org' | 'library' } | null — the exercise’s effective media for the org. |
read_workouts_get movements | mediaUrl (stored per occurrence) and media (source: 'workout' when the occurrence has its own, else the exercise’s effective media). |
Telemetry
mcp.tools.listed— everytools/list, withtoolCount.mcp.tool.executed— everytools/call:tool,ok,errorCode,errorDetailCode,errorMessage(≤300 chars),errorIssues(forinvalid_input:path:zodCodeper failing field, ≤300 chars — never the rejected value; the same{ path, code }list reaches the caller aserror.details.issues),durationMs,client(user agent),unmatchedNamesforread_exercises_resolve_batch, andmcpSessionIdwhen the client sends anmcp-session-idheader. Programming writes (workouts_create,workouts_set_sections,workouts_upsert,workouts_parse,programs_session_create) add their shape, flat:sectionCount,partCount,movementCount,slotCount,sourceTextSections,scoring,mode,hasSessionType,hasDescription,updated,warningsCount,warningCodes(distinct warning codes, sorted), and for a publishsortOrder/optional/drip(now,morning_of, ornone= draft). Counts and enums only — never titles, notes or source text.mcp.request_failed— auth failures and transport-level 4xx/5xx.
Organization
A user can be staff in several orgs. The server resolves the org per request:
?orgId=<uuid>on the server URL → that org, if the caller is active staff there.- No
orgIdand exactly one staff membership → that org. - No
orgIdand several → HTTP 400organization_required, listing{ id, name }for each org, so the user can add?orgId=to the connector URL.
Authentication
Taikan is an OAuth protected resource; Clerk is the authorization server. Nothing proprietary.
- Unauthenticated calls get
401withWWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp". GET /.well-known/oauth-protected-resource[/mcp]names${API_BASE_URL}/mcpas the resource and the Clerk Frontend API (derived fromCLERK_PUBLISHABLE_KEY) as the authorization server.API_BASE_URLmust be the exact origin clients connect to — clients reject metadata whoseresourcediffers from their URL. Running the API on another port or behind a tunnel locally means overriding it to match.GET /.well-known/oauth-authorization-servermirrors Clerk’s metadata for older clients that look for it on the resource origin.- Accepted bearer tokens: Clerk OAuth access tokens (verified via Clerk’s API; revoked/expired rejected) and Clerk session JWTs (local tooling). The dev/test
x-test-user-idbypass works under the same fail-closed gate asAuthGuard. - Stateless: every request re-verifies the token and re-reads the membership, so revocation or a role change applies on the next call.
Not in scope (yet)
- Confirm-gated writes (needs MCP elicitation support to be reliable across clients).
- Undo (
inverse) — Spotter’s undo is tied to its conversation records. - AI spend limits — MCP calls spend no Taikan AI budget; the global request throttler still applies.
- Members.
Setup (one-time, per Clerk instance)
In the Clerk Dashboard → Configure → OAuth applications, enable Dynamic client registration so MCP clients can register themselves. Without it, clients fail at the OAuth step with a registration error.
Connecting a client
Owners and admins get the link from Settings → Integrations → AI assistants (MCP): it is pinned to the studio they are in (?orgId=), has a copy button, and carries a short per-client guide. The card is not flagged. Coaches can use the server but cannot open that settings page, so they need the link shared with them.
| Environment | Server URL |
|---|---|
| Local | http://localhost:3001/mcp |
| Production | https://<api host>/mcp |
Append ?orgId=<org uuid> if you are staff in more than one studio.
- Claude (claude.ai / desktop) — Settings → Connectors → Add custom connector, paste the URL. Claude runs the OAuth flow against Clerk.
- Claude Code —
claude mcp add --transport http taikan <url>, then/mcpto authenticate. - Cursor — add to
.cursor/mcp.json:{ "mcpServers": { "taikan": { "url": "<url>" } } }. - MCP Inspector (local debugging) —
npx @modelcontextprotocol/inspector, transport Streamable HTTP, URLhttp://localhost:3001/mcp, and either run the OAuth flow or add anAuthorization: Bearer <Clerk session token>header (copy one from the web app viaawait window.Clerk.session.getToken()). Use the Inspector’s proxy connection — the API’s CORS allow-list does not include the Inspector origin.
With TEST_AUTH_BYPASS=true and NODE_ENV=development, a header x-test-user-id: <clerk user id> skips the token entirely.
For deterministic source reconciliation of coaching sessions, see the scheduled programming sync guide.