389 lines
14 KiB
JavaScript
389 lines
14 KiB
JavaScript
#!/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<string> {
|
|
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<Response> {
|
|
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<string, unknown>;
|
|
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);
|
|
});
|