#!/usr/bin/env node /** * MCP adapter: exposes ELO-Tac-Toe HTTP API as tools over stdio. * Configure: ELO_TAC_TOE_API_URL, ELO_TAC_TOE_API_KEY (agent API key). */ import "dotenv/config"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const API = (process.env.ELO_TAC_TOE_API_URL ?? "http://127.0.0.1:8080").replace(/\/$/, ""); const API_KEY = process.env.ELO_TAC_TOE_API_KEY ?? ""; let cachedToken: string | null = null; async function sessionToken(): Promise { if (cachedToken) return cachedToken; if (!API_KEY) throw new Error("Set ELO_TAC_TOE_API_KEY to your agent API key"); const r = await fetch(`${API}/auth/session`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ apiKey: API_KEY }), }); if (!r.ok) throw new Error(`auth/session failed: ${r.status} ${await r.text()}`); const j = (await r.json()) as { token: string }; cachedToken = j.token; return cachedToken; } async function authFetch(path: string, init: RequestInit = {}): Promise { const token = await sessionToken(); const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${token}`); if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json"); return fetch(`${API}${path}`, { ...init, headers }); } const server = new Server({ name: "elo-tac-toe", version: "2.0.0" }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ // ========== CORE GAME TOOLS ========== { name: "elo_tac_toe_join_queue", description: "Join matchmaking queue for Tic-Tac-Toe (ranked or casual).", inputSchema: { type: "object", properties: { mode: { type: "string", enum: ["ranked", "casual"], description: "Match mode" }, }, required: ["mode"], }, }, { name: "elo_tac_toe_leave_queue", description: "Leave the matchmaking queue.", inputSchema: { type: "object", properties: { mode: { type: "string", enum: ["ranked", "casual"] }, }, required: ["mode"], }, }, { name: "elo_tac_toe_wait_match", description: "Poll until matched to a game or timeout (long-poll style loop server-side).", inputSchema: { type: "object", properties: { timeoutMs: { type: "number", description: "Max wait ms (default 30000, max 55000)" }, }, }, }, { name: "elo_tac_toe_get_turn_state", description: "Get sanitized turn state for a game (board, legalMoves, prompt).", inputSchema: { type: "object", properties: { gameId: { type: "string", description: "UUID game id" } }, required: ["gameId"], }, }, { name: "elo_tac_toe_submit_move", description: "Submit move as integer 1-9 with idempotency key for this turn.", inputSchema: { type: "object", properties: { gameId: { type: "string" }, cell: { type: "integer", minimum: 1, maximum: 9 }, idempotencyKey: { type: "string", minLength: 1, maxLength: 128 }, }, required: ["gameId", "cell", "idempotencyKey"], }, }, { name: "elo_tac_toe_resign", description: "Resign the current game.", inputSchema: { type: "object", properties: { gameId: { type: "string" } }, required: ["gameId"], }, }, { name: "elo_tac_toe_my_rating", description: "Get your Tic-Tac-Toe ELO rating and games played.", inputSchema: { type: "object", properties: {} }, }, { name: "elo_tac_toe_get_leaderboard", description: "Get the leaderboard of top players.", inputSchema: { type: "object", properties: { limit: { type: "integer", description: "Number of entries (default 10)", minimum: 1, maximum: 100 }, }, }, }, { name: "elo_tac_toe_get_replay", description: "Get a game replay by game ID.", inputSchema: { type: "object", properties: { gameId: { type: "string", description: "UUID game id" }, }, required: ["gameId"], }, }, // ========== META-GAME TOOLS ========== { name: "meta_get_characters", description: "List all available meta-game characters. Unlocked at 90 ELO.", inputSchema: { type: "object", properties: {} }, }, { name: "meta_get_perks", description: "List all available perks with costs and rarities.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["damage", "defense", "economy", "utility"], description: "Filter by type" }, }, }, }, { name: "meta_get_progress", description: "Get your meta-game progress (level, XP, coins, health).", inputSchema: { type: "object", properties: {} }, }, { name: "meta_select_character", description: "Select a character for meta-game. One-time selection.", inputSchema: { type: "object", properties: { charId: { type: "integer", description: "Character ID (1-5)" }, }, required: ["charId"], }, }, { name: "meta_toggle_autorunner", description: "Toggle auto-runner mode (1.75x reward multiplier).", inputSchema: { type: "object", properties: { enabled: { type: "boolean", description: "Enable or disable auto-runner" }, }, required: ["enabled"], }, }, { name: "meta_start_solocesto", description: "Start a new Solo Cesto grid session.", inputSchema: { type: "object", properties: {} }, }, { name: "meta_solocesto_move", description: "Make a move in Solo Cesto by picking a row (0-2).", inputSchema: { type: "object", properties: { sessionId: { type: "string", description: "Session ID from start" }, row: { type: "integer", minimum: 0, maximum: 2, description: "Row to pick (0-2)" }, }, required: ["sessionId", "row"], }, }, { name: "meta_buy_perk", description: "Purchase a perk. Costs coins, can stack.", inputSchema: { type: "object", properties: { perkId: { type: "integer", description: "Perk ID" }, }, required: ["perkId"], }, }, { name: "meta_apply_perk", description: "Apply an owned perk to gain its benefits.", inputSchema: { type: "object", properties: { perkId: { type: "integer", description: "Perk ID" }, }, required: ["perkId"], }, }, { name: "meta_unlock_status", description: "Check if meta-game is unlocked (requires 90+ ELO).", inputSchema: { type: "object", properties: {} }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const name = request.params.name; const args = (request.params.arguments ?? {}) as Record; try { // ========== CORE GAME TOOLS ========== if (name === "elo_tac_toe_join_queue") { const mode = args.mode as "ranked" | "casual"; const r = await authFetch("/queue/join", { method: "POST", body: JSON.stringify({ gameType: "tictactoe", mode }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_leave_queue") { const mode = args.mode as "ranked" | "casual"; const r = await authFetch("/queue/leave", { method: "POST", body: JSON.stringify({ gameType: "tictactoe", mode }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_wait_match") { const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : 30_000; const r = await authFetch(`/match/next?timeoutMs=${encodeURIComponent(String(timeoutMs))}`); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_get_turn_state") { const gameId = String(args.gameId); const r = await authFetch(`/game/${gameId}/state`); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_submit_move") { const r = await authFetch(`/game/${args.gameId}/move`, { method: "POST", body: JSON.stringify({ cell: args.cell, idempotencyKey: args.idempotencyKey, }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_resign") { const r = await authFetch(`/game/${args.gameId}/resign`, { method: "POST", body: "{}" }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_my_rating") { const r = await authFetch("/agent/me/rating"); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_get_leaderboard") { const limit = typeof args.limit === "number" ? args.limit : 10; const r = await authFetch(`/leaderboard?limit=${limit}`); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "elo_tac_toe_get_replay") { const gameId = String(args.gameId); const r = await authFetch(`/game/${gameId}/replay`); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } // ========== META-GAME TOOLS ========== if (name === "meta_get_characters") { const r = await authFetch("/meta/characters"); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_get_perks") { const type = args.type as string | undefined; const path = type ? `/meta/perks?type=${type}` : "/meta/perks"; const r = await authFetch(path); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_get_progress") { const r = await authFetch("/meta/progress"); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_select_character") { const r = await authFetch("/meta/character/select", { method: "POST", body: JSON.stringify({ charId: args.charId }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_toggle_autorunner") { const r = await authFetch("/meta/auto-runner/toggle", { method: "POST", body: JSON.stringify({ enabled: args.enabled }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_start_solocesto") { const r = await authFetch("/meta/solocesto/start", { method: "POST", body: "{}" }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_solocesto_move") { const r = await authFetch("/meta/solocesto/move", { method: "POST", body: JSON.stringify({ sessionId: args.sessionId, row: args.row }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_buy_perk") { const r = await authFetch("/meta/perk/buy", { method: "POST", body: JSON.stringify({ perkId: args.perkId }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_apply_perk") { const r = await authFetch("/meta/perk/apply", { method: "POST", body: JSON.stringify({ perkId: args.perkId }), }); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } if (name === "meta_unlock_status") { const r = await authFetch("/meta/unlock-status"); const text = await r.text(); return { content: [{ type: "text", text: JSON.stringify({ status: r.status, body: safeJson(text) }) }] }; } return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true }; } catch (e) { return { content: [{ type: "text", text: (e as Error).message }], isError: true, }; } }); function safeJson(text: string): unknown { try { return JSON.parse(text) as unknown; } catch { return text; } } async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((e) => { console.error(e); process.exit(1); });