Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesMcp ServerMCP server

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.action becomes one flat MCP tool named router_action (e.g. read.members_search → read_members_search, tasks.create → tasks_create). No router multiplexing.
  • inputSchema is the leaf’s Zod schema as JSON Schema (input side: defaulted fields are optional).
  • annotations.readOnlyHint mirrors sideEffects: '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_picker tools (read.ask_user_to_pick) — the MCP client asks its own user instead.
  • 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_tool error as a tool that does not exist.
  • Tool errors come back as isError: true with { 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 instructionsOne 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_parseListed 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 descriptionsMCP serves day-grain wording (mcp.summary); Spotter keeps the shared summary.
MCP section writesworkouts_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_upsert writes one day in one call: workout fields (title, description = the brief, coachNote, sessionType, scoring?, timeCap, programId), an optional date (YYYY-MM-DD), a required externalRef (e.g. <program>:<date>), and parts: [{ key, title, note?, optional?, clockStartSeconds?, clockEndSeconds?, sections }] and/or flat sections (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 (needs workout_builder). It goes through WorkoutsService.create, which updates the workout an existing externalRef names and replaces its sections; a re-send clears the description, coachNote, sessionType and timeCap it leaves out, while an omitted programId keeps the current program. A write without title is named by dayWorkoutTitle (libs/shared/src/lib/workout-title.ts) — dailyWorkoutTitle(date, program name) when date is sent, else the part titles joined ” · ” — on create and on the externalRef update alike, so the brief never becomes the name lists show. Returns { id, created, updated, title, sectionCount, partCount, movementCount, scoring, warnings }. Audited as workout.upsert. In Spotter, a section carrying sourceText or scores is refused (Source text and score slots require the MCP channel) and nothing is written.
  • workouts_parse runs the coach’s text for one day through the paste parser (see workout parse) and returns a proposal in the workouts_upsert input 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.title is always set: save.title, else a title the text itself states (never the brief, a part or block heading, or a blank/“null” answer), else dailyWorkoutTitle(date, program name) when date is known (the program from save.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 }, where prescription is what the parser read off the line, so confirming the movement keeps its numbers.
    • save writes only when every movement matched and nothing in the text needs review; otherwise saved: false and notSavedReasons. To confirm suggestions and save in one call, re-send the same text and save with resolutions taken from unresolved (each exerciseId one of that movement’s candidates or another exercise the org can use). A resolution matches by mention — 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 in notSavedReasons.
    • warnings carries the fidelity warnings above plus auto_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’s sourceText, tappable there, and becomes one movement’s mediaUrl (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’s alternatives (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, scaling levels, percent_reference loads with a named reference (never percent_1rm off a 3RM), EMOM intervalSecondsMax for “Every 3:00-3:30”, group_load for a complex, and movement alternatives for “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.

ToolBehavior
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_customOptional mediaUrl sets the new exercise’s video; on an existing same-name org exercise it replaces that row’s video.
workouts_set_sections, workouts_upsertOptional 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 candidatesmedia: { url, kind: 'video' | 'web', source: 'org' | 'library' } | null — the exercise’s effective media for the org.
read_workouts_get movementsmediaUrl (stored per occurrence) and media (source: 'workout' when the occurrence has its own, else the exercise’s effective media).

Telemetry

  • mcp.tools.listed — every tools/list, with toolCount.
  • mcp.tool.executed — every tools/call: tool, ok, errorCode, errorDetailCode, errorMessage (≤300 chars), errorIssues (for invalid_input: path:zodCode per failing field, ≤300 chars — never the rejected value; the same { path, code } list reaches the caller as error.details.issues), durationMs, client (user agent), unmatchedNames for read_exercises_resolve_batch, and mcpSessionId when the client sends an mcp-session-id header. 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 publish sortOrder / optional / drip (now, morning_of, or none = 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:

  1. ?orgId=<uuid> on the server URL → that org, if the caller is active staff there.
  2. No orgId and exactly one staff membership → that org.
  3. No orgId and several → HTTP 400 organization_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 401 with WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp".
  • GET /.well-known/oauth-protected-resource[/mcp] names ${API_BASE_URL}/mcp as the resource and the Clerk Frontend API (derived from CLERK_PUBLISHABLE_KEY) as the authorization server. API_BASE_URL must be the exact origin clients connect to — clients reject metadata whose resource differs from their URL. Running the API on another port or behind a tunnel locally means overriding it to match.
  • GET /.well-known/oauth-authorization-server mirrors 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-id bypass works under the same fail-closed gate as AuthGuard.
  • 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.

EnvironmentServer URL
Localhttp://localhost:3001/mcp
Productionhttps://<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 /mcp to authenticate.
  • Cursor — add to .cursor/mcp.json: { "mcpServers": { "taikan": { "url": "<url>" } } }.
  • MCP Inspector (local debugging) — npx @modelcontextprotocol/inspector, transport Streamable HTTP, URL http://localhost:3001/mcp, and either run the OAuth flow or add an Authorization: Bearer <Clerk session token> header (copy one from the web app via await 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.