No AI on the server: building Gonkbot
Gonkbot is a remote MCP server for logging golf rounds. MCP (Model Context Protocol) is the standard way to give an AI assistant tools over HTTP. You add Gonkbot as a connector in ChatGPT, Claude, Gemini, Grok, or Poke, then tell the assistant what happened on each hole as you play.
The server never calls a model. The Worker has two bindings, D1 and static assets, and no model API key. The golfer’s assistant turns “bogey on 8, missed the green left, two putts” into a tool call. Gonkbot stores it and does the math. No inference bill, no chat UI to build. But I don’t control the model on the other end, so the server has to make mistakes hard to make and easy to recover from.
Six tools, on purpose
Every tool schema sits in the client’s context on every turn, so the list is short and task-shaped:
lookup_course: tees and scorecard, or a nearby/by-designer listlog_round: the whole in-round loopget_rounds: scorecards, stats, CSV exportget_insights: cross-round trends and the internal handicap indexplayer_profile: coach memory and stock bag yardagessave_recap: persist the post-round review
log_round does most of the work. Its input is a Zod discriminated union on action (start, record_holes, correct, finish, abandon, reopen, update_tees), so each action has its own required fields. Every response ends with a STATE: block (round id, holes logged, running score) so an assistant that loses context mid-round can re-anchor. Retries carry idempotency keys scoped to the action. Failures are prose starting with ERROR: or CONFIRM: that the model can act on.
The first version had five tools. Poke, reviewing the deployed server, pointed out it couldn’t answer course questions. I added lookup_course, pulled it in favor of guidance in llms.txt to protect the tool budget, then put it back. Clients learn what a server can do from its tool schemas, not a text file on its website. The rule now: a new tool has to be necessary in-band and impossible to get by extending an existing one.
Connecting the assistant is the signup
There’s no signup form. Clerk is the OAuth 2.1 authorization server, handling dynamic client registration, consent, and tokens. The Worker is only the protected resource. An unauthenticated request to /mcp gets a 401 pointing at RFC 9728 metadata:
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: {
"WWW-Authenticate": `Bearer realm="OAuth", resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`,
},
});
The client follows that to Clerk, the golfer signs in with Google or email, and the Worker checks the token with @clerk/backend. The first authenticated request creates the user row in D1. That’s the whole account flow.
The first version ran a self-hosted workers-oauth-provider with Google upstream. Moving to Clerk deleted roughly 700 lines of security-critical code.
Unofficial handicap math
The rule in the World Handicap System (WHS) module is never to emit a silently wrong number. The core formulas (18-hole case):
// Course Handicap = Index x (Slope / 113) + (Rating - Par), rounded.
const ch = Math.round(index * (slope / 113) + (rating - coursePar));
// Score Differential = (113 / Slope) x (AGS - Rating), one decimal. PCC omitted.
const diff = Math.round((113 / slope) * (adjustedGross - rating) * 10) / 10;
Adjusted gross caps each hole at net double bogey: par + 2 + strokes received. Strokes follow the card’s stroke indexes. Without them, Gonkbot orders holes by par and yardage and flags the result as estimated. The index uses the WHS table for fewer than 20 scores (best 1 of 3 minus 2, up to best 8 of 20), truncates to one decimal, and caps at 54. It needs 54 holes across at least three rounds.
Nine-hole rounds were the tricky part. WHS 2024 adds an “expected” differential for the unplayed nine, and the official table is proprietary. I use a community approximation, 0.52 * index + 1.2. The expected score depends on the index, which depends on the combined differentials, so it solves by fixed-point iteration.
The first version also got nine holes wrong in a simpler way: it scored them against the full 18-hole rating, so a 41 came out as a differential of -28.2 instead of +5.0. The code fix took an evening. The data took longer. Differentials are stored when a round finishes, and two rounds finished in the 90 minutes before the fix went live kept the bad number, which the index kept counting. A migration recomputed only the rows that matched the old formula. Now I treat any stored, derived number as needing a backfill plan, not just a code fix.
Scorecards and rating/slope import from OpenGolfAPI, an open ODbL course database, when a round starts. Handicap math waits until the golfer confirms rating and slope. Every figure is labeled unofficial.
GHIN has no API, so: post-assist
GHIN has no public API. Apps that post scores are licensed USGA partners in the Golfer Product Access (GPA) program, which carries a $6,000/yr fee. The reverse-engineered endpoints sign in with the golfer’s GHIN password. Gonkbot is OAuth-only and never asks for one.
So finish returns a GHIN posting summary. With confirmed rating/slope and an index, it gives adjusted gross with the capped holes listed. Otherwise it says to post gross and let GHIN adjust. If the golfer finds the slope later, update_tees attaches it and recomputes the differential without reopening the card. The GPA application is drafted. Submitting it is a business decision.
Coach memory
player_profile holds a markdown essay (tendencies, miss patterns, swing protocols, goals) and a stock bag of carry and total yardages. The model writing to it may not be the one that last read it, so writes use optimistic concurrency: reads return a revision token, writes compare-and-swap in SQL, and blind overwrites are rejected.
The review_round prompt ties it together. The assistant pulls the latest round with shot detail, loads trends and the profile, asks three to five pointed questions about specific holes, writes a structured recap, saves it, and merges what it learned into the profile. Comparisons are against the golfer’s own averages, not tour stats.
Same URL, different install flow
Every client connects to https://gonkbot.com/mcp. Getting there differs:
- Claude: a prefilled add-connector link on desktop. The phone app can’t add custom connectors, so phones go to
/install/claudeand add it in Safari. - ChatGPT: no install link, no store listing. Paid plan, web app on a computer, Developer mode, a custom connector with OAuth, enabled per chat.
- Gemini: a custom app for Spark under Connected Apps, added on a computer, or
gemini mcp addin Gemini CLI. - Grok: grok.com/connectors, New Connector, Custom.
- Poke: a recipe link, or
npx poke@latest mcp add.
llms.txt has a section per host so an assistant helping with setup follows only its own steps.
The official MCP registry
Gonkbot has been in the official MCP registry as com.gonkbot/mcp since August 27. Domain auth meant serving an Ed25519 public key at /.well-known/mcp-registry-auth, then running mcp-publisher publish with a server.json. That helps agents find the server, but it doesn’t put a button inside ChatGPT or Claude. Their directories are separate submissions.
Stack
TypeScript on Cloudflare Workers, MCP SDK v2 over Streamable HTTP, Clerk, and OpenGolfAPI. The server is stateless: each request builds a fresh McpServer bound to the authenticated user via the Agents SDK’s createMcpHandler, and all state lives in D1. Vitest covers the stats and WHS math, and an end-to-end suite plays full rounds against a local Worker. Pushes to main run both, then deploy.