Joffrey Thomas commited on
Commit
bea46e9
·
1 Parent(s): 08ec225
chess_game/chess_server.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ from typing import Dict, List, Optional
4
+
5
+ import chess
6
+ import chess.pgn
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+
10
+ mcp = FastMCP(name="ChessServer", stateless_http=True)
11
+
12
+
13
+ # Simple in-memory game state keyed by session_id
14
+ games: Dict[str, Dict] = {}
15
+
16
+
17
+ def _get_session_id() -> str:
18
+ # Provide a default single-session id when not given by client
19
+ return "default"
20
+
21
+
22
+ def _ensure_game(session_id: str) -> Dict:
23
+ if session_id not in games:
24
+ games[session_id] = {
25
+ "board": chess.Board(),
26
+ "pgn_game": chess.pgn.Game(),
27
+ "node": None,
28
+ }
29
+ games[session_id]["node"] = games[session_id]["pgn_game"]
30
+ return games[session_id]
31
+
32
+
33
+ @mcp.tool(description="Start a new chess game. Returns initial board state.")
34
+ def start_game(session_id: Optional[str] = None, player_color: str = "white") -> Dict:
35
+ sid = session_id or _get_session_id()
36
+ game = {
37
+ "board": chess.Board(),
38
+ "pgn_game": chess.pgn.Game(),
39
+ "node": None,
40
+ }
41
+ if player_color.lower() not in ("white", "black"):
42
+ player_color = "white"
43
+ # If AI should play first (player is black), make an AI opening move
44
+ if player_color.lower() == "black":
45
+ # Let engine choose a random legal move
46
+ ai_move = random.choice(list(game["board"].legal_moves))
47
+ game["board"].push(ai_move)
48
+ game["node"] = game["pgn_game"]
49
+ games[sid] = game
50
+ return _board_state(game["board"]) | {"session_id": sid, "player_color": player_color}
51
+
52
+
53
+ def _board_state(board: chess.Board) -> Dict:
54
+ return {
55
+ "fen": board.fen(),
56
+ "unicode": board.unicode(borders=True),
57
+ "turn": "white" if board.turn else "black",
58
+ "is_game_over": board.is_game_over(),
59
+ "result": board.result() if board.is_game_over() else None,
60
+ "legal_moves": [board.san(m) for m in board.legal_moves],
61
+ }
62
+
63
+
64
+ @mcp.tool(description="Make a player move in SAN or UCI notation.")
65
+ def player_move(move: str, session_id: Optional[str] = None) -> Dict:
66
+ sid = session_id or _get_session_id()
67
+ g = _ensure_game(sid)
68
+ board: chess.Board = g["board"]
69
+ try:
70
+ try:
71
+ chess_move = board.parse_san(move)
72
+ except ValueError:
73
+ chess_move = chess.Move.from_uci(move)
74
+ if chess_move not in board.legal_moves:
75
+ raise ValueError("Illegal move")
76
+ board.push(chess_move)
77
+ # Update PGN
78
+ g["node"] = g["node"].add_variation(chess_move)
79
+ return _board_state(board) | {"last_move": board.san(chess_move)}
80
+ except Exception as e:
81
+ return {"error": f"Invalid move: {e}"}
82
+
83
+
84
+ @mcp.tool(description="Have the AI make a move (random legal move).")
85
+ def ai_move(session_id: Optional[str] = None) -> Dict:
86
+ sid = session_id or _get_session_id()
87
+ g = _ensure_game(sid)
88
+ board: chess.Board = g["board"]
89
+ if board.is_game_over():
90
+ return _board_state(board)
91
+ move = random.choice(list(board.legal_moves))
92
+ board.push(move)
93
+ g["node"] = g["node"].add_variation(move)
94
+ return _board_state(board) | {"last_move": board.san(move)}
95
+
96
+
97
+ @mcp.tool(description="Return current board state.")
98
+ def board(session_id: Optional[str] = None) -> Dict:
99
+ sid = session_id or _get_session_id()
100
+ g = _ensure_game(sid)
101
+ return _board_state(g["board"]) | {"session_id": sid}
102
+
103
+
104
+ @mcp.tool(description="List legal moves in SAN notation.")
105
+ def legal_moves(session_id: Optional[str] = None) -> List[str]:
106
+ sid = session_id or _get_session_id()
107
+ g = _ensure_game(sid)
108
+ b: chess.Board = g["board"]
109
+ return [b.san(m) for m in b.legal_moves]
110
+
111
+
112
+ @mcp.tool(description="Game status including check, checkmate, stalemate.")
113
+ def status(session_id: Optional[str] = None) -> Dict:
114
+ sid = session_id or _get_session_id()
115
+ g = _ensure_game(sid)
116
+ b: chess.Board = g["board"]
117
+ return {
118
+ "turn": "white" if b.turn else "black",
119
+ "is_check": b.is_check(),
120
+ "is_checkmate": b.is_checkmate(),
121
+ "is_stalemate": b.is_stalemate(),
122
+ "is_insufficient_material": b.is_insufficient_material(),
123
+ "is_game_over": b.is_game_over(),
124
+ "result": b.result() if b.is_game_over() else None,
125
+ }
126
+
127
+
128
+ @mcp.tool(description="Export game PGN.")
129
+ def pgn(session_id: Optional[str] = None) -> str:
130
+ sid = session_id or _get_session_id()
131
+ g = _ensure_game(sid)
132
+ game = g["pgn_game"]
133
+ exporter = chess.pgn.StringExporter(headers=True, variations=False, comments=False)
134
+ return game.accept(exporter)
135
+
136
+
chess_game/static/style.css ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif;
3
+ margin: 0; padding: 0; background: #0b1020; color: #f4f4f8;
4
+ }
5
+ .container { max-width: 900px; margin: 0 auto; padding: 16px; }
6
+ a { color: #93c5fd; text-decoration: none; }
7
+ button { background: #2563eb; color: white; border: 0; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
8
+ button:hover { background: #1d4ed8; }
9
+ input, select { padding: 8px; border-radius: 6px; border: 1px solid #334155; background: #0f172a; color: #e5e7eb; }
10
+ ul { padding-left: 20px; }
11
+
12
+
chess_game/templates/index.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Chess Rooms</title>
6
+ <link rel="stylesheet" href="{{ base_path }}/static/style.css">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <style>
9
+ pre { white-space: pre-wrap; }
10
+ </style>
11
+ <link rel="icon" href="data:,">
12
+ <meta property="og:title" content="Chess MCP" />
13
+ <meta property="og:description" content="Play chess against an AI via MCP" />
14
+ <meta property="og:image" content="/static/embed.png" />
15
+ <meta name="twitter:card" content="summary_large_image" />
16
+ <meta name="twitter:title" content="Chess MCP" />
17
+ <meta name="twitter:description" content="Play chess against an AI via MCP" />
18
+ <meta name="twitter:image" content="/static/embed.png" />
19
+ <meta name="robots" content="noai" />
20
+ <meta name="ai-access-control" content="none" />
21
+ </head>
22
+ <body>
23
+ <div class="container">
24
+ <h1>Chess MCP Rooms</h1>
25
+ <form method="post" action="{{ base_path }}/create_room">
26
+ <button type="submit">Create Room</button>
27
+ </form>
28
+
29
+ <h2>Join Room</h2>
30
+ <form onsubmit="event.preventDefault(); location.href='{{ base_path }}/room/' + document.getElementById('rid').value;">
31
+ <input id="rid" placeholder="Enter room id" required />
32
+ <button type="submit">Join</button>
33
+ </form>
34
+
35
+ <h2>Active Rooms</h2>
36
+ <ul>
37
+ {% for rid, info in rooms.items() %}
38
+ <li><a href="{{ base_path }}/room/{{ rid }}">{{ rid }}</a></li>
39
+ {% else %}
40
+ <li>No rooms yet. Create one!</li>
41
+ {% endfor %}
42
+ </ul>
43
+ </div>
44
+ </body>
45
+ </html>
46
+
47
+
chess_game/templates/room.html ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Chess Room {{ room_id }}</title>
6
+ <link rel="stylesheet" href="{{ base_path }}/static/style.css">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <style>
9
+ pre.board { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 14px; line-height: 1.2; }
10
+ .controls { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
11
+ .status { margin-top: 8px; }
12
+ </style>
13
+ <link rel="icon" href="data:,">
14
+ </head>
15
+ <body>
16
+ <div class="container">
17
+ <a href="{{ base_path }}/">← Back</a>
18
+ <h1>Room: {{ room_id }}</h1>
19
+
20
+ <div class="controls">
21
+ <select id="color">
22
+ <option value="white">White</option>
23
+ <option value="black">Black</option>
24
+ </select>
25
+ <button id="startBtn">Start / Reset</button>
26
+ <input id="moveInput" placeholder="Enter SAN or UCI (e4, Nf3, e7e5)" />
27
+ <button id="moveBtn">Make Move</button>
28
+ <button id="aiBtn">AI Move</button>
29
+ <button id="copyPgnBtn">Copy PGN</button>
30
+ </div>
31
+
32
+ <pre id="board" class="board">{{ state.unicode }}</pre>
33
+ <div class="status">
34
+ <div><strong>Turn:</strong> <span id="turn"></span></div>
35
+ <div><strong>Result:</strong> <span id="result"></span></div>
36
+ <div><strong>Legal Moves:</strong> <span id="legal"></span></div>
37
+ </div>
38
+ </div>
39
+
40
+ <script>
41
+ const roomId = {{ room_id | tojson }};
42
+ const basePath = {{ base_path | tojson }};
43
+
44
+ async function getJSON(path, options) {
45
+ const res = await fetch(path, options);
46
+ if (!res.ok) {
47
+ const msg = await res.text();
48
+ throw new Error(msg || res.statusText);
49
+ }
50
+ return res.json();
51
+ }
52
+
53
+ async function refresh() {
54
+ const state = await getJSON(`${basePath}/room/${roomId}/board`);
55
+ document.getElementById('board').textContent = state.unicode;
56
+ const s = await getJSON(`${basePath}/room/${roomId}/status`);
57
+ document.getElementById('turn').textContent = s.turn + (s.is_check ? ' (check)' : '');
58
+ document.getElementById('result').textContent = s.is_game_over ? (s.result || 'game over') : '-';
59
+ const legal = await getJSON(`${basePath}/room/${roomId}/legal_moves`);
60
+ document.getElementById('legal').textContent = legal.join(', ');
61
+ }
62
+
63
+ document.getElementById('startBtn').addEventListener('click', async () => {
64
+ const color = document.getElementById('color').value;
65
+ await getJSON(`${basePath}/room/${roomId}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ player_color: color })});
66
+ await refresh();
67
+ });
68
+
69
+ document.getElementById('moveBtn').addEventListener('click', async () => {
70
+ const mv = document.getElementById('moveInput').value.trim();
71
+ if (!mv) return;
72
+ try {
73
+ await getJSON(`${basePath}/room/${roomId}/player_move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ move: mv })});
74
+ } catch (e) { alert('Move error: ' + e.message); }
75
+ await refresh();
76
+ });
77
+
78
+ document.getElementById('aiBtn').addEventListener('click', async () => {
79
+ await getJSON(`${basePath}/room/${roomId}/ai_move`, { method: 'POST' });
80
+ await refresh();
81
+ });
82
+
83
+ document.getElementById('copyPgnBtn').addEventListener('click', async () => {
84
+ const data = await getJSON(`${basePath}/room/${roomId}/pgn`);
85
+ await navigator.clipboard.writeText(data.pgn || '');
86
+ alert('PGN copied to clipboard');
87
+ });
88
+
89
+ refresh();
90
+ </script>
91
+ </body>
92
+ </html>
93
+
94
+
chess_game/web_app.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+
4
+ from fastapi import FastAPI, Request, HTTPException
5
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.templating import Jinja2Templates
8
+
9
+ from .chess_server import start_game as m_start_game, player_move as m_player_move, ai_move as m_ai_move, board as m_board, legal_moves as m_legal_moves, status as m_status, pgn as m_pgn
10
+
11
+
12
+ BASE_DIR = os.path.dirname(__file__)
13
+ TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
14
+ STATIC_DIR = os.path.join(BASE_DIR, "static")
15
+
16
+
17
+ app = FastAPI()
18
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
19
+ templates = Jinja2Templates(directory=TEMPLATES_DIR)
20
+
21
+
22
+ # Simple room registry for UI convenience (MCP state keyed by room_id in chess_server)
23
+ rooms = {}
24
+
25
+
26
+ def generate_room_id() -> str:
27
+ return uuid.uuid4().hex[:8]
28
+
29
+
30
+ @app.get("/", response_class=HTMLResponse)
31
+ def home(request: Request):
32
+ base_path = request.scope.get('root_path', '')
33
+ return templates.TemplateResponse("index.html", {"request": request, "rooms": rooms, "base_path": base_path})
34
+
35
+
36
+ @app.post("/create_room")
37
+ def create_room(request: Request):
38
+ # Choose a new room id and redirect to room page; player picks color there
39
+ room_id = generate_room_id()
40
+ rooms[room_id] = {"id": room_id}
41
+ return RedirectResponse(url=f"{request.scope.get('root_path', '')}/room/{room_id}", status_code=303)
42
+
43
+
44
+ @app.get("/room/{room_id}", response_class=HTMLResponse)
45
+ def room_page(room_id: str, request: Request):
46
+ base_path = request.scope.get('root_path', '')
47
+ # Fetch board (this also ensures MCP game exists lazily)
48
+ state = m_board(session_id=room_id)
49
+ if room_id not in rooms:
50
+ rooms[room_id] = {"id": room_id}
51
+ return templates.TemplateResponse("room.html", {"request": request, "room_id": room_id, "state": state, "base_path": base_path})
52
+
53
+
54
+ # --- API: Map to MCP tools ---
55
+
56
+
57
+ @app.post("/room/{room_id}/start")
58
+ def api_start(room_id: str, payload: dict | None = None):
59
+ player_color = (payload or {}).get("player_color", "white")
60
+ state = m_start_game(session_id=room_id, player_color=player_color)
61
+ return JSONResponse(state)
62
+
63
+
64
+ @app.post("/room/{room_id}/player_move")
65
+ def api_player_move(room_id: str, payload: dict):
66
+ if not payload or "move" not in payload:
67
+ raise HTTPException(status_code=400, detail="Missing 'move' in body")
68
+ result = m_player_move(move=payload["move"], session_id=room_id)
69
+ if "error" in result:
70
+ raise HTTPException(status_code=400, detail=result["error"])
71
+ return JSONResponse(result)
72
+
73
+
74
+ @app.post("/room/{room_id}/ai_move")
75
+ def api_ai_move(room_id: str):
76
+ result = m_ai_move(session_id=room_id)
77
+ return JSONResponse(result)
78
+
79
+
80
+ @app.get("/room/{room_id}/board")
81
+ def api_board(room_id: str):
82
+ return JSONResponse(m_board(session_id=room_id))
83
+
84
+
85
+ @app.get("/room/{room_id}/legal_moves")
86
+ def api_legal_moves(room_id: str):
87
+ return JSONResponse(m_legal_moves(session_id=room_id))
88
+
89
+
90
+ @app.get("/room/{room_id}/status")
91
+ def api_status(room_id: str):
92
+ return JSONResponse(m_status(session_id=room_id))
93
+
94
+
95
+ @app.get("/room/{room_id}/pgn")
96
+ def api_pgn(room_id: str):
97
+ return JSONResponse({"pgn": m_pgn(session_id=room_id)})
98
+
99
+
requirements.txt CHANGED
@@ -4,4 +4,5 @@ pydantic>=2.7.0
4
  selectolax>=0.3.15
5
  fastapi
6
  jinja2
7
- Pillow
 
 
4
  selectolax>=0.3.15
5
  fastapi
6
  jinja2
7
+ Pillow
8
+ python-chess>=1.999
server.py CHANGED
@@ -5,7 +5,9 @@ from fastapi.staticfiles import StaticFiles
5
  from fastapi.templating import Jinja2Templates
6
  from pokemon.pokemon_server import mcp as pokemon_mcp
7
  from geoguessr.geo_server import mcp as geogussr_mcp
 
8
  from geoguessr.web_app import app as geoguessr_app
 
9
  import os
10
 
11
 
@@ -15,6 +17,7 @@ async def lifespan(app: FastAPI):
15
  async with contextlib.AsyncExitStack() as stack:
16
  await stack.enter_async_context(pokemon_mcp.session_manager.run())
17
  await stack.enter_async_context(geogussr_mcp.session_manager.run())
 
18
  yield
19
 
20
 
@@ -42,9 +45,11 @@ async def index(request: Request):
42
 
43
  app.mount("/geoguessr", geogussr_mcp.streamable_http_app())
44
  app.mount("/Pokemon", pokemon_mcp.streamable_http_app())
 
45
 
46
  # Mount GeoGuessr FastAPI web app (UI + API)
47
  app.mount("/geoguessr_app", geoguessr_app)
 
48
 
49
  PORT = int(os.environ.get("PORT", "10000"))
50
 
 
5
  from fastapi.templating import Jinja2Templates
6
  from pokemon.pokemon_server import mcp as pokemon_mcp
7
  from geoguessr.geo_server import mcp as geogussr_mcp
8
+ from chess_game.chess_server import mcp as chess_mcp
9
  from geoguessr.web_app import app as geoguessr_app
10
+ from chess_game.web_app import app as chess_app
11
  import os
12
 
13
 
 
17
  async with contextlib.AsyncExitStack() as stack:
18
  await stack.enter_async_context(pokemon_mcp.session_manager.run())
19
  await stack.enter_async_context(geogussr_mcp.session_manager.run())
20
+ await stack.enter_async_context(chess_mcp.session_manager.run())
21
  yield
22
 
23
 
 
45
 
46
  app.mount("/geoguessr", geogussr_mcp.streamable_http_app())
47
  app.mount("/Pokemon", pokemon_mcp.streamable_http_app())
48
+ app.mount("/chess", chess_mcp.streamable_http_app())
49
 
50
  # Mount GeoGuessr FastAPI web app (UI + API)
51
  app.mount("/geoguessr_app", geoguessr_app)
52
+ app.mount("/chess_app", chess_app)
53
 
54
  PORT = int(os.environ.get("PORT", "10000"))
55