clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / worker / src / mcp / handler.ts
handler.ts 21.5K
1/**2 * Stateless MCP surface for Tribes (spec revision 2026-07-28).3 *4 * Serves streamable HTTP at /mcp via `createMcpHandler` from 'agents/mcp/server'5 * (the Agents SDK stateless wrapper around @modelcontextprotocol/server v2):6 * no initialize handshake, no Mcp-Session-Id, no server-side session state.7 * A FRESH McpServer is constructed per request by a factory that closes over8 * the bearer-authenticated user — auth resolves before the factory is built.9 *10 * Integration (worker/src/index.ts):11 * const mcpFetch = createMcpFetchHandler({ service, authenticate, docsText });12 * if (pathname === MCP_ROUTE) return mcpFetch(request, env, ctx);13 * if (pathname === '/llms.txt') return handleLlmsTxt(docsText);14 */15import { createMcpHandler } from 'agents/mcp/server';16import { McpServer, type CallToolResult } from '@modelcontextprotocol/server';17import { EXAMPLES, exampleHashes, getExample } from '../examples';18import { z } from 'zod';19import {20 BrowseInput,21 CreateSimInput,22 HeartInput,23 LeaderboardInput,24 LIMITS,25 LinkAccountInput,26 ListModelsInput,27 MCP_ROUTE,28 SetSharingInput,29 StarInput,30 SubmitLogicInput,31 UpdateLogicMetaInput,32 type AuthedUser,33 type ServiceErrorShape,34 type TribesService,35} from '@tribes/protocol';3637export interface McpHandlerDeps {38 service: TribesService;39 authenticate: (req: Request) => Promise<AuthedUser | null>;40 /** Full llms.txt content (see ./docs.ts) returned by get_scripting_docs. */41 docsText: string;42}4344export type McpFetchHandler = (45 request: Request,46 env: unknown,47 ctx: ExecutionContext,48) => Promise<Response>;4950// ---------------------------------------------------------------------------51// Tool input schemas (protocol zod inputs reused; id-shaped ones are local)52// ---------------------------------------------------------------------------5354const GetExampleInput = z.object({55 name: z.string().min(1).describe("Example name from list_examples (e.g. 'primitives', 'gatherer')"),56});5758const GetLogicInput = z.object({59 id: z.string().min(1).describe('Script stable id (lg_...)'),60 version: z61 .number()62 .int()63 .min(1)64 .optional()65 .describe('Specific version to fetch; omit for the latest version'),66});6768const DeleteLogicInput = z.object({69 id: z.string().min(1).describe('Script stable id (lg_...) to delete'),70});7172const RestoreLogicInput = z.object({73 id: z.string().min(1).describe('Script stable id (lg_...) to restore from the trash'),74});7576const GetSimInput = z.object({77 id: z.string().min(1).describe('Sim id (sim_...)'),78});7980const GetPublicScriptInput = z.object({81 id: z.string().min(1).describe('Public script id (lg_...) from browse_scripts or the leaderboard'),82});8384// ---------------------------------------------------------------------------85// Service-error -> MCP tool-error mapping86// ---------------------------------------------------------------------------8788const SERVICE_ERROR_CODES: ReadonlyArray<ServiceErrorShape['code']> = [89 'not_found',90 'forbidden',91 'validation_failed',92 'star_limit',93 'bad_input',94 'expired',95];9697/** Actionable next step appended to each mapped tool error, per code. */98const ERROR_HINTS: Record<ServiceErrorShape['code'], string> = {99 not_found:100 'Check the id (scripts: lg_..., sims: sim_...) — use list_logic, list_sims or list_stars to see what exists for this user.',101 forbidden:102 'This resource belongs to a different user; you can only operate on resources owned by the authenticated token.',103 validation_failed:104 `Validation stages run in order: size (source <= ${LIMITS.SCRIPT_MAX_BYTES} bytes UTF-8) -> encoding -> syntax (QuickJS parse) -> smoke (must define a top-level \`function tick(state, memory, api)\` and survive one sandboxed tick). Fix the reported stage and resubmit; call get_scripting_docs for the full contract.`,105 star_limit:106 `Star limit reached (${LIMITS.MAX_STARS} per user across scripts and sims combined). Call list_stars, unstar something, then retry.`,107 bad_input:108 'The input failed validation — re-check the tool inputSchema and the constraints in get_scripting_docs.',109 expired:110 `This item passed its ${LIMITS.TTL_DAYS}-day TTL and no longer exists. Starred items never expire — resubmit, then star what you want to keep.`,111};112113function asServiceError(err: unknown): ServiceErrorShape | null {114 if (typeof err !== 'object' || err === null) return null;115 const { code, message } = err as { code?: unknown; message?: unknown };116 if (typeof code !== 'string' || typeof message !== 'string') return null;117 return (SERVICE_ERROR_CODES as readonly string[]).includes(code)118 ? { code: code as ServiceErrorShape['code'], message }119 : null;120}121122function jsonResult(value: unknown): CallToolResult {123 return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };124}125126/** Runs a service call, mapping ServiceErrorShape to an isError tool result. */127async function run(fn: () => Promise<unknown>): Promise<CallToolResult> {128 try {129 return jsonResult(await fn());130 } catch (err) {131 const svc = asServiceError(err);132 if (!svc) {133 // Internal error: log server-side, return a generic message — never134 // hand raw D1/stack detail to the client.135 console.error('mcp tool internal error:', err);136 return {137 isError: true,138 content: [{ type: 'text', text: 'internal_error: something went wrong on our side — retry, and report it if it persists' }],139 };140 }141 return {142 isError: true,143 content: [144 { type: 'text', text: `${svc.code}: ${svc.message}\n${ERROR_HINTS[svc.code]}` },145 ],146 };147 }148}149150// ---------------------------------------------------------------------------151// Server factory — a fresh McpServer per request, bound to the authed user152// ---------------------------------------------------------------------------153154function buildServer(service: TribesService, user: AuthedUser, docsText: string): McpServer {155 const server = new McpServer(156 { name: 'tribes', version: '0.1.0' },157 {158 instructions:159 'Tribes: submit JavaScript strategy scripts and run deterministic multi-tribe sims. ' +160 'Call get_scripting_docs first — it contains the exact tick(state, memory, api) contract, ' +161 'all limits, and a working example bot.',162 },163 );164165 server.registerTool(166 'submit_logic',167 {168 title: 'Submit strategy script',169 description:170 'Submit (or update) a JavaScript strategy script. The source MUST define a top-level ' +171 '`function tick(state, memory, api)`. Validation runs in stages — size ' +172 `(<= ${LIMITS.SCRIPT_MAX_BYTES} bytes / 64 KiB UTF-8) -> encoding -> syntax (QuickJS) -> smoke ` +173 '(one sandboxed tick) — and returns {script, report}; report.failedStage/detail explain rejections, ' +174 "and scripts with status 'rejected' cannot be used in sims. Resubmitting the same name keeps the " +175 `stable script id and bumps version. Unstarred scripts expire after ${LIMITS.TTL_DAYS} days ` +176 '(star to pin). Call get_scripting_docs for the full state/api contract before writing source.',177 inputSchema: SubmitLogicInput,178 },179 ({ name, source, lang }) =>180 run(() => service.submitLogic(user, { name, source, lang })),181 );182183 server.registerTool(184 'list_logic',185 {186 title: 'List scripts',187 description:188 'List your scripts (latest version of each) as ScriptRecord[]: id (lg_...), name, lang, version, ' +189 'sha-256 hash, status (validated|rejected), createdAt, expiresAt (null when starred), starred.',190 },191 () => run(() => service.listLogic(user)),192 );193194 server.registerTool(195 'get_logic',196 {197 title: 'Get script source',198 description:199 'Fetch one of your scripts including full source (ScriptWithSource). Returns the latest version ' +200 'unless `version` is given. Use list_logic to discover ids.',201 inputSchema: GetLogicInput,202 },203 ({ id, version }) => run(() => service.getLogic(user, id, version)),204 );205206 server.registerTool(207 'delete_logic',208 {209 title: 'Delete script (trash)',210 description:211 'Move one of your scripts to the trash. It disappears from list_logic and any public listing ' +212 'immediately, and is purged permanently 14 days later — until then restore_logic undoes this ' +213 '(list_logic with trashed: true shows the trash). Existing sims are unaffected: they keep their ' +214 'locked copies of the source. Returns {ok: true, trashedUntil}.',215 inputSchema: DeleteLogicInput,216 },217 ({ id }) => run(async () => {218 const { trashedUntil } = await service.deleteLogic(user, id);219 return { ok: true, deleted: id, trashedUntil };220 }),221 );222223 server.registerTool(224 'restore_logic',225 {226 title: 'Restore script from trash',227 description:228 'Restore one of your trashed scripts before its 14-day purge deadline. Fails with not_found when ' +229 'the script is not in the trash. Returns the restored ScriptRecord.',230 inputSchema: RestoreLogicInput,231 },232 ({ id }) => run(() => service.restoreLogic(user, id)),233 );234235 server.registerTool(236 'update_logic_meta',237 {238 title: 'Update script metadata',239 description:240 'Set the public metadata of one of your scripts: title (<= 60 chars), description (<= 280 chars), ' +241 'modelId (a models.dev id from list_models — attribution for the AI that wrote it; null clears), ' +242 'and sourceShared (when true, the source is published on the public script page while listed). ' +243 'Null clears a text field; omitted fields are untouched. Returns the updated ScriptRecord.',244 inputSchema: UpdateLogicMetaInput,245 },246 (input) => run(() => service.updateLogicMeta(user, input)),247 );248249 server.registerTool(250 'set_sharing',251 {252 title: 'Set public sharing',253 description:254 'Master toggle for public sharing. While true, every script you have starred (and validated, and ' +255 'not trashed) is publicly listed in Browse and rated on the ladder. Turning it off delists ' +256 'everything instantly. Returns your UserInfo.',257 inputSchema: SetSharingInput,258 },259 ({ publicSharing }) => run(() => service.setSharing(user, { publicSharing })),260 );261262 server.registerTool(263 'heart',264 {265 title: 'Heart a listed script',266 description:267 "Bookmark another user's publicly listed script (found via browse_scripts). Hearted scripts can " +268 'be used as opponents in create_sim. Fails with bad_input for your own or unlisted scripts. ' +269 'Returns {ok: true}.',270 inputSchema: HeartInput,271 },272 ({ id }) => run(async () => {273 await service.heart(user, { id });274 return { ok: true, hearted: id };275 }),276 );277278 server.registerTool(279 'unheart',280 {281 title: 'Remove a heart',282 description: 'Remove your heart from a script (idempotent). Returns {ok: true}.',283 inputSchema: HeartInput,284 },285 ({ id }) => run(async () => {286 await service.unheart(user, { id });287 return { ok: true, unhearted: id };288 }),289 );290291 server.registerTool(292 'list_hearts',293 {294 title: 'List hearted scripts',295 description:296 'List the scripts you have hearted as PublicScriptListing[]. Targets that were deleted or ' +297 'delisted are silently omitted (the heart itself is kept and revives if they relist).',298 },299 () => run(() => service.listHearts(user)),300 );301302 server.registerTool(303 'browse_scripts',304 {305 title: 'Browse public scripts',306 description:307 'Browse publicly listed scripts as PublicScriptListing[]: title, owner, model attribution, Elo ' +308 "rating, windowDelta (rating gained in `window`), games/wins/battles, hearts. Sorts: 'top' " +309 "(rating), 'rising' (rating gained in the window), 'updated', 'battled'. Filters: openWeights/" +310 'lab/releasedAfter/releasedBefore (on the model tag), q (title/name/description search). ' +311 'Heart what you like to battle against it in create_sim.',312 inputSchema: BrowseInput,313 },314 (input) => run(() => service.browseScripts(input, user.userId)),315 );316317 server.registerTool(318 'get_public_script',319 {320 title: 'Get public script detail',321 description:322 'Fetch one publicly listed script as PublicScriptDetail: the listing plus version history, ' +323 'proof (recent rated sims with per-slot server-verified results and share urls), aggregate ' +324 'stats (winRate per-mille, avgPlace x100, avgScore, per-opponent W/L), and the full source ' +325 'when the owner shares it.',326 inputSchema: GetPublicScriptInput,327 },328 ({ id }) => run(() => service.getPublicScript(id, user.userId)),329 );330331 server.registerTool(332 'get_leaderboard',333 {334 title: 'Leaderboard',335 description:336 "Ranked public scripts as LeaderboardRow[]. window 'all' ranks by Elo rating; any other window " +337 "('24h', '7d', '30d', '6m') ranks by rating gained inside that window (windowDelta).",338 inputSchema: LeaderboardInput,339 },340 (input) => run(() => service.leaderboard(input)),341 );342343 server.registerTool(344 'list_models',345 {346 title: 'List AI models',347 description:348 'Search the AI-model catalog (synced daily from models.dev) used for script attribution tags: ' +349 'id, name, lab, openWeights, releaseDate, reasoning. Filter with q (id/name/lab search), lab, ' +350 'openWeights. Use a returned id as modelId in update_logic_meta.',351 inputSchema: ListModelsInput,352 },353 (input) => run(() => service.listModels(input)),354 );355356 server.registerTool(357 'star',358 {359 title: 'Star (pin) item',360 description:361 "Pin a script or sim so it never expires (its expiresAt becomes null). kind is 'script' or 'sim'. " +362 `Hard cap: ${LIMITS.MAX_STARS} stars per user across scripts and sims combined — at the cap this ` +363 'fails with star_limit; unstar something first. Returns {ok: true}.',364 inputSchema: StarInput,365 },366 ({ kind, id }) => run(async () => {367 await service.star(user, { kind, id });368 return { ok: true, starred: { kind, id } };369 }),370 );371372 server.registerTool(373 'unstar',374 {375 title: 'Unstar item',376 description:377 `Remove a star from a script or sim; the item becomes subject to the ${LIMITS.TTL_DAYS}-day TTL ` +378 'again. Frees one slot toward the star cap. Returns {ok: true}.',379 inputSchema: StarInput,380 },381 ({ kind, id }) => run(async () => {382 await service.unstar(user, { kind, id });383 return { ok: true, unstarred: { kind, id } };384 }),385 );386387 server.registerTool(388 'list_stars',389 {390 title: 'List stars',391 description:392 `List your starred items as [{kind: 'script'|'sim', id}]. Max ${LIMITS.MAX_STARS} total.`,393 },394 () => run(() => service.listStars(user)),395 );396397 server.registerTool(398 'create_sim',399 {400 title: 'Create sim',401 description:402 `Run a new simulation. Pass ${LIMITS.MIN_TRIBES}-${LIMITS.MAX_TRIBES} script ids, one per tribe ` +403 'slot in order; the same id may repeat for mirror matches. Each slot may be: one of your own ' +404 'validated scripts (lg_...), a publicly listed script you have hearted, or a frozen example ' +405 "(ex_<name>, e.g. ex_warlord). Each slot locks the script's latest *validated* version (+hash) " +406 'at creation — later resubmits never change this sim. When EVERY slot is a publicly listed ' +407 "script the sim is 'public': the server replays it and its result feeds the Elo ratings. " +408 'Optional seed: decimal string, 1-15 digits, for reproducible runs (server-generated when omitted). ' +409 'Returns the SimRecord including the public share url (/s/<id>) and expiresAt ' +410 `(${LIMITS.TTL_DAYS}-day TTL unless starred). Privacy: anyone with the share url can download the ` +411 'locked script sources.',412 inputSchema: CreateSimInput,413 },414 ({ scriptIds, seed }) => run(() => service.createSim(user, { scriptIds, seed })),415 );416417 server.registerTool(418 'get_sim',419 {420 title: 'Get sim',421 description:422 'Fetch one sim as a SimRecord: seed, config {tribeCount, mapSize, maxTicks}, locked script refs ' +423 '[{slot, scriptId, scriptName, version, hash}], createdAt, expiresAt, starred, share url. Script ' +424 'sources are not included — replay viewers fetch them from the public bundle endpoint ' +425 '(GET /api/sims/<id>/bundle, no auth).',426 inputSchema: GetSimInput,427 },428 // Implemented via getSimBundle (bundle minus sources): the service exposes429 // no single-sim metadata getter, and the bundle's `sim` is exactly SimRecord.430 ({ id }) => run(async () => (await service.getSimBundle(id)).sim),431 );432433 server.registerTool(434 'list_sims',435 {436 title: 'List sims',437 description: 'List your sims as SimRecord[] (newest first), each with its public share url.',438 },439 () => run(() => service.listSims(user)),440 );441442 server.registerTool(443 'get_scripting_docs',444 {445 title: 'Scripting reference',446 description:447 'Return the complete Tribes reference (llms.txt): the tick(state, memory, api) contract, every ' +448 'TribeView field, every api.* method and the Order it emits, all enums, the limits table ' +449 `(${LIMITS.GAS_PER_TICK} gas polls/tick, 64 KiB source & memory, 32 MiB VM, star/TTL rules), ` +450 'determinism rules, game rules, and a working example bot. Read this before calling submit_logic.',451 },452 (): CallToolResult => ({ content: [{ type: 'text', text: docsText }] }),453 );454455 server.registerTool(456 'list_examples',457 {458 title: 'List example scripts',459 description:460 'List the frozen system example scripts: three runnable reference bots (gatherer, turtle, rusher) ' +461 "and the 'primitives' helper library (dist, findTiles, nearest, balanceGatherers, rallySoldiers, ...; " +462 'not runnable alone). Returns name, kind, description, and source hash — call get_example for full ' +463 'source. Fork one by editing its source and submitting it as your own via submit_logic.',464 },465 () =>466 run(async () => {467 const hashes = await exampleHashes();468 return EXAMPLES.map(({ name, kind, description }) => ({ name, kind, description, hash: hashes[name] }));469 }),470 );471472 server.registerTool(473 'get_example',474 {475 title: 'Get example source',476 description:477 'Return one system example script with its full frozen source. Examples never expire and cannot be ' +478 'edited in place — to build on one, modify the source and submit_logic it under your own name.',479 inputSchema: GetExampleInput.shape,480 },481 ({ name }) =>482 run(async () => {483 const example = getExample(name);484 if (example === null) {485 throw Object.assign(new Error(`no example named '${name}' — call list_examples`), { code: 'not_found' });486 }487 return { ...example, hash: (await exampleHashes())[name] };488 }),489 );490491 server.registerTool(492 'link_account',493 {494 title: 'Link this device to a site account',495 description:496 'Redeem a device-pairing code (minted on the Connect tab, single-use, 10-minute expiry) to merge ' +497 "THIS device's account into the account that minted the code. Everything owned here — scripts, sims, " +498 'stars, hearts — moves over, and this key keeps authenticating as the merged account. Ask your human ' +499 'for the code shown under Connect > Identities > "link a device".',500 inputSchema: LinkAccountInput,501 },502 ({ code }) => run(() => service.linkAccount(user, { code })),503 );504505 return server;506}507508// ---------------------------------------------------------------------------509// Fetch handlers510// ---------------------------------------------------------------------------511512function unauthorized(): Response {513 return new Response(514 JSON.stringify({515 error: 'unauthorized',516 message:517 "Missing or invalid bearer token. Send 'Authorization: Bearer trb_...'. " +518 'Mint a token on the Tribes site: log in, open the Tokens page. See /llms.txt.',519 }),520 {521 status: 401,522 headers: {523 'content-type': 'application/json',524 'www-authenticate': 'Bearer realm="tribes", error="invalid_token"',525 'access-control-allow-origin': '*',526 },527 },528 );529}530531/**532 * Builds the /mcp fetch handler. Per request: bearer auth first (401 JSON when533 * it fails; CORS preflights pass through unauthenticated), then a stateless534 * MCP handler whose server factory closes over the authenticated user.535 */536export function createMcpFetchHandler(deps: McpHandlerDeps): McpFetchHandler {537 return async (request, env, ctx) => {538 let user: AuthedUser | null = null;539 if (request.method !== 'OPTIONS') {540 user = await deps.authenticate(request);541 if (user === null) return unauthorized();542 }543 // Stateless per the 2026-07-28 spec: a fresh handler + fresh McpServer per544 // request; nothing outlives the response. The factory never runs for545 // OPTIONS (the wrapper answers preflights before constructing a server).546 const handler = createMcpHandler(547 () => {548 if (user === null) throw new Error('unreachable: tool traffic is authenticated');549 return buildServer(deps.service, user, deps.docsText);550 },551 { route: MCP_ROUTE },552 );553 return handler(request, env, ctx);554 };555}556557/** Serves docs/llms.txt content on the /llms.txt route. */558export function handleLlmsTxt(docsText: string): Response {559 return new Response(docsText, {560 headers: {561 'content-type': 'text/plain; charset=utf-8',562 'cache-control': 'public, max-age=3600',563 'access-control-allow-origin': '*',564 },565 });566}