elo-tac-toe-mcp/mcp-server.ts

197 lines
6.8 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: "1.0.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
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: {} },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const name = request.params.name;
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
try {
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) }) }] };
}
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);
});