chess-mcp-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chess_mcp_server-0.1.0.dist-info/METADATA +111 -0
- chess_mcp_server-0.1.0.dist-info/RECORD +11 -0
- chess_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- chess_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- chess_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +0 -0
- src/chess_engine.py +132 -0
- src/game_state.py +134 -0
- src/mcp_server.py +215 -0
- src/rendering.py +233 -0
- src/web_dashboard.py +66 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chess-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Model Context Protocol (MCP) server for playing Chess with AI Agents.
|
|
5
|
+
Project-URL: Homepage, https://github.com/fritzprix/chess-mcp-server
|
|
6
|
+
Project-URL: Issues, https://github.com/fritzprix/chess-mcp-server/issues
|
|
7
|
+
Author-email: fritzprix <your-email@example.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: fastapi
|
|
15
|
+
Requires-Dist: jinja2
|
|
16
|
+
Requires-Dist: mcp[cli]
|
|
17
|
+
Requires-Dist: python-chess
|
|
18
|
+
Requires-Dist: uvicorn
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# โ๏ธ Chess MCP Server
|
|
22
|
+
|
|
23
|
+
**Give your AI Agent eyes to see the board and hands to make the move.**
|
|
24
|
+
|
|
25
|
+
This is not just a chess API. It's a **Model Context Protocol (MCP)** server designed to let Large Language Models (LLMs) like Claude play chess *agentically*.
|
|
26
|
+
|
|
27
|
+
Capable of visualizing the board in real-time HTML, understanding spatial relationships via Markdown, and challenging you with a hybrid difficulty engine (Levels 1-10)โor simply facilitating a game between you and your Agent.
|
|
28
|
+
|
|
29
|
+
## ๐ Features
|
|
30
|
+
|
|
31
|
+
- **MCP-UI Support**: Interactive HTML board embedded directly in the chat (where supported).
|
|
32
|
+
- **Hybrid AI Engine**: Adjustable difficulty from "Random Blunderer" (Level 1) to "Minimax Master" (Level 10).
|
|
33
|
+
- **Agent vs. Agent**: Let two AI personalities battle it out.
|
|
34
|
+
- **Web Dashboard**: Automatically launches a local sidecar dashboard (`http://localhost:8080`) to monitor all active games.
|
|
35
|
+
|
|
36
|
+
## ๐ฆ Installation
|
|
37
|
+
|
|
38
|
+
### Prerequisites
|
|
39
|
+
- Python 3.10+
|
|
40
|
+
- An MCP Client (e.g., [Claude Desktop](https://claude.ai/download), [Cursor](https://cursor.sh/))
|
|
41
|
+
|
|
42
|
+
### 1. Installation
|
|
43
|
+
You can install directly from PyPI:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install chess-mcp-server
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Configure MCP Client
|
|
50
|
+
|
|
51
|
+
Add the following to your MCP Client configuration file (e.g., `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"chess": {
|
|
57
|
+
"command": "uvx",
|
|
58
|
+
"args": ["chess-mcp-server"]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
*Alternatively, using pip installation:*
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"chess": {
|
|
69
|
+
"command": "python",
|
|
70
|
+
"args": ["-m", "src.mcp_server"]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## ๐ ๏ธ Development
|
|
77
|
+
|
|
78
|
+
If you want to modify the code:
|
|
79
|
+
|
|
80
|
+
1. **Clone & Setup**
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/fritzprix/chess-mcp-server.git
|
|
83
|
+
cd chess-mcp-server
|
|
84
|
+
|
|
85
|
+
python -m venv .venv
|
|
86
|
+
source .venv/bin/activate
|
|
87
|
+
pip install -r requirements.txt
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## ๐ฎ How to Play
|
|
91
|
+
|
|
92
|
+
Once the server is connected, you can ask your Agent to start a game.
|
|
93
|
+
|
|
94
|
+
### Start a Game
|
|
95
|
+
Ask: *"Start a new chess game against the computer at level 5."*
|
|
96
|
+
- The Agent calls `createGame`.
|
|
97
|
+
- **Pro Tip**: You can also ask *"I want to play against YOU. Create a game where you are White."*
|
|
98
|
+
|
|
99
|
+
### The Game Loop
|
|
100
|
+
1. **Your Move**:
|
|
101
|
+
- Interact with the **HTML Board** if shown. Drag your piece and click **Confirm**.
|
|
102
|
+
- *Or* tell the Agent: *"Move pawn to e4."*
|
|
103
|
+
2. **Agent's Turn**:
|
|
104
|
+
- The Agent calls `waitForNextTurn`.
|
|
105
|
+
- It sees the board (Markdown or HTML) and thinks about the move.
|
|
106
|
+
- It calls `finishTurn` to submit its move.
|
|
107
|
+
3. **Checkmate**:
|
|
108
|
+
- If you deliver the final blow, you can check the **"Claim Checkmate"** box on the UI or tell the Agent *"Checkmate!"*.
|
|
109
|
+
|
|
110
|
+
### Dashboard
|
|
111
|
+
When the server starts, it will try to open **http://localhost:8080**. You can view the list of all active games and spectator views there.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
src/chess_engine.py,sha256=VO3JAfVSJq3MG-qlMwr2TI4nPYBt8eGr0IBtMCO8FhQ,4707
|
|
3
|
+
src/game_state.py,sha256=h5uvGRB75OqhGat9bYRzg-EtJHYb9Idz_r_hNTxbiC4,4510
|
|
4
|
+
src/mcp_server.py,sha256=NcL0NE4yCagILm9hDvVM7D1mGgcUMUEObx1umwCzoM0,7739
|
|
5
|
+
src/rendering.py,sha256=Yrh6z-Uq14DR2iXpnYna-QlZQYyLZ7uuRwrv04xz60o,9570
|
|
6
|
+
src/web_dashboard.py,sha256=oFwsjIz6U6E_UekxfMax8o-dG16-irvLvZKCfMMgEbk,2048
|
|
7
|
+
chess_mcp_server-0.1.0.dist-info/METADATA,sha256=0f2S4R7Vi013jonmyhnjKQCnzqh03dEbBawUHJxPcVY,3596
|
|
8
|
+
chess_mcp_server-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
chess_mcp_server-0.1.0.dist-info/entry_points.txt,sha256=_vYs2ugzerBvNKlCJb5BmABHOBAtE1qVwGwV_PZ5LTI,57
|
|
10
|
+
chess_mcp_server-0.1.0.dist-info/licenses/LICENSE,sha256=V6qi4H9nPJfHJh1BzEW77U3yh7l8vd_IOrNJbo93TFQ,1066
|
|
11
|
+
chess_mcp_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fritzprix
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
src/__init__.py
ADDED
|
File without changes
|
src/chess_engine.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import chess
|
|
2
|
+
import math
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
class ChessAI:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
# Configuration for 10 levels
|
|
8
|
+
self.levels = {
|
|
9
|
+
1: {"depth": 1, "error_rate": 0.60},
|
|
10
|
+
2: {"depth": 1, "error_rate": 0.40},
|
|
11
|
+
3: {"depth": 1, "error_rate": 0.20},
|
|
12
|
+
4: {"depth": 2, "error_rate": 0.20},
|
|
13
|
+
5: {"depth": 2, "error_rate": 0.10},
|
|
14
|
+
6: {"depth": 3, "error_rate": 0.10},
|
|
15
|
+
7: {"depth": 3, "error_rate": 0.05},
|
|
16
|
+
8: {"depth": 3, "error_rate": 0.00},
|
|
17
|
+
9: {"depth": 4, "error_rate": 0.05},
|
|
18
|
+
10: {"depth": 4, "error_rate": 0.00},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Piece values for evaluation
|
|
22
|
+
# Simple material evaluation
|
|
23
|
+
self.piece_values = {
|
|
24
|
+
chess.PAWN: 10, chess.KNIGHT: 30, chess.BISHOP: 30,
|
|
25
|
+
chess.ROOK: 50, chess.QUEEN: 90, chess.KING: 900
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def get_move(self, board: chess.Board, level: int) -> chess.Move:
|
|
29
|
+
"""
|
|
30
|
+
Returns the best move based on the difficulty level (1-10).
|
|
31
|
+
"""
|
|
32
|
+
if level not in self.levels:
|
|
33
|
+
level = 5 # Default to medium
|
|
34
|
+
|
|
35
|
+
settings = self.levels[level]
|
|
36
|
+
depth = settings["depth"]
|
|
37
|
+
error_rate = settings["error_rate"]
|
|
38
|
+
|
|
39
|
+
# 1. Error Chance (Simulate Blunder)
|
|
40
|
+
# If the random roll is within the error rate, pick a random legal move.
|
|
41
|
+
legal_moves = list(board.legal_moves)
|
|
42
|
+
if not legal_moves:
|
|
43
|
+
return None # No moves available (Checkmate/Stalemate)
|
|
44
|
+
|
|
45
|
+
if random.random() < error_rate:
|
|
46
|
+
return random.choice(legal_moves)
|
|
47
|
+
|
|
48
|
+
# 2. Strategic Calculation (Minimax)
|
|
49
|
+
# Otherwise, calculate the best move using Minimax.
|
|
50
|
+
return self._get_best_move_minimax(board, depth)
|
|
51
|
+
|
|
52
|
+
def _evaluate_board(self, board):
|
|
53
|
+
if board.is_checkmate():
|
|
54
|
+
return -9999 if board.turn else 9999
|
|
55
|
+
if board.is_stalemate() or board.is_insufficient_material():
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
score = 0
|
|
59
|
+
for square in chess.SQUARES:
|
|
60
|
+
piece = board.piece_at(square)
|
|
61
|
+
if piece:
|
|
62
|
+
value = self.piece_values[piece.piece_type]
|
|
63
|
+
if piece.color == chess.WHITE:
|
|
64
|
+
score += value
|
|
65
|
+
else:
|
|
66
|
+
score -= value
|
|
67
|
+
return score
|
|
68
|
+
|
|
69
|
+
def _minimax(self, board, depth, alpha, beta, maximizing):
|
|
70
|
+
if depth == 0 or board.is_game_over():
|
|
71
|
+
return self._evaluate_board(board)
|
|
72
|
+
|
|
73
|
+
legal_moves = list(board.legal_moves)
|
|
74
|
+
|
|
75
|
+
if maximizing:
|
|
76
|
+
max_eval = -math.inf
|
|
77
|
+
for move in legal_moves:
|
|
78
|
+
board.push(move)
|
|
79
|
+
eval = self._minimax(board, depth - 1, alpha, beta, False)
|
|
80
|
+
board.pop()
|
|
81
|
+
max_eval = max(max_eval, eval)
|
|
82
|
+
alpha = max(alpha, eval)
|
|
83
|
+
if beta <= alpha:
|
|
84
|
+
break
|
|
85
|
+
return max_eval
|
|
86
|
+
else:
|
|
87
|
+
min_eval = math.inf
|
|
88
|
+
for move in legal_moves:
|
|
89
|
+
board.push(move)
|
|
90
|
+
eval = self._minimax(board, depth - 1, alpha, beta, True)
|
|
91
|
+
board.pop()
|
|
92
|
+
min_eval = min(min_eval, eval)
|
|
93
|
+
beta = min(beta, eval)
|
|
94
|
+
if beta <= alpha:
|
|
95
|
+
break
|
|
96
|
+
return min_eval
|
|
97
|
+
|
|
98
|
+
def _get_best_move_minimax(self, board, depth):
|
|
99
|
+
best_move = None
|
|
100
|
+
best_value = -math.inf if board.turn == chess.WHITE else math.inf
|
|
101
|
+
alpha = -math.inf
|
|
102
|
+
beta = math.inf
|
|
103
|
+
|
|
104
|
+
legal_moves = list(board.legal_moves)
|
|
105
|
+
if not legal_moves:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# Simple optimization: shuffle moves to prevent deterministic behavior on equal scores
|
|
109
|
+
random.shuffle(legal_moves)
|
|
110
|
+
|
|
111
|
+
for move in legal_moves:
|
|
112
|
+
board.push(move)
|
|
113
|
+
# Pass opposite of current turn because we just pushed a move and now it's opponent's turn to minimize/maximize
|
|
114
|
+
value = self._minimax(board, depth - 1, alpha, beta, not board.turn)
|
|
115
|
+
board.pop()
|
|
116
|
+
|
|
117
|
+
if board.turn == chess.WHITE:
|
|
118
|
+
if value > best_value:
|
|
119
|
+
best_value = value
|
|
120
|
+
best_move = move
|
|
121
|
+
alpha = max(alpha, best_value)
|
|
122
|
+
else:
|
|
123
|
+
if value < best_value:
|
|
124
|
+
best_value = value
|
|
125
|
+
best_move = move
|
|
126
|
+
beta = min(beta, best_value)
|
|
127
|
+
|
|
128
|
+
# Fallback if no best move found (should rely on random legal or standard)
|
|
129
|
+
if best_move is None and legal_moves:
|
|
130
|
+
return random.choice(legal_moves)
|
|
131
|
+
|
|
132
|
+
return best_move
|
src/game_state.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import uuid
|
|
3
|
+
import chess
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from .chess_engine import ChessAI
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class GameInstance:
|
|
10
|
+
id: str
|
|
11
|
+
board: chess.Board
|
|
12
|
+
config: dict
|
|
13
|
+
# Event to notify when a move is made (wakes up waitForNextTurn)
|
|
14
|
+
move_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
15
|
+
|
|
16
|
+
# AI Engine if playing vs Computer
|
|
17
|
+
ai: Optional[ChessAI] = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_game_over(self):
|
|
21
|
+
return self.board.is_game_over()
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def result(self):
|
|
25
|
+
if not self.is_game_over:
|
|
26
|
+
return None
|
|
27
|
+
outcome = self.board.outcome()
|
|
28
|
+
if outcome.winner == chess.WHITE:
|
|
29
|
+
return "White wins"
|
|
30
|
+
elif outcome.winner == chess.BLACK:
|
|
31
|
+
return "Black wins"
|
|
32
|
+
else:
|
|
33
|
+
return "Draw"
|
|
34
|
+
|
|
35
|
+
class GameManager:
|
|
36
|
+
_instance = None
|
|
37
|
+
|
|
38
|
+
def __new__(cls):
|
|
39
|
+
if cls._instance is None:
|
|
40
|
+
cls._instance = super(GameManager, cls).__new__(cls)
|
|
41
|
+
cls._instance.games: Dict[str, GameInstance] = {}
|
|
42
|
+
return cls._instance
|
|
43
|
+
|
|
44
|
+
def create_game(self, config: dict) -> GameInstance:
|
|
45
|
+
game_id = str(uuid.uuid4())[:8]
|
|
46
|
+
board = chess.Board()
|
|
47
|
+
|
|
48
|
+
ai = None
|
|
49
|
+
if config.get("type") == "computer":
|
|
50
|
+
ai = ChessAI()
|
|
51
|
+
|
|
52
|
+
game = GameInstance(
|
|
53
|
+
id=game_id,
|
|
54
|
+
board=board,
|
|
55
|
+
config=config,
|
|
56
|
+
ai=ai
|
|
57
|
+
)
|
|
58
|
+
self.games[game_id] = game
|
|
59
|
+
return game
|
|
60
|
+
|
|
61
|
+
def get_game(self, game_id: str) -> Optional[GameInstance]:
|
|
62
|
+
return self.games.get(game_id)
|
|
63
|
+
|
|
64
|
+
def list_games(self):
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
"id": g.id,
|
|
68
|
+
"fen": g.board.fen(),
|
|
69
|
+
"type": g.config.get("type"),
|
|
70
|
+
"turn": "White" if g.board.turn == chess.WHITE else "Black"
|
|
71
|
+
}
|
|
72
|
+
for g in self.games.values()
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
async def make_move(self, game_id: str, move_uci: str, claim_win: bool = False) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Executes a move.
|
|
78
|
+
Returns 'OK' or raises generic exceptions.
|
|
79
|
+
Triggers computer move if applicable.
|
|
80
|
+
"""
|
|
81
|
+
game = self.get_game(game_id)
|
|
82
|
+
if not game:
|
|
83
|
+
raise ValueError(f"Game {game_id} not found")
|
|
84
|
+
|
|
85
|
+
# Parse Move
|
|
86
|
+
try:
|
|
87
|
+
move = chess.Move.from_uci(move_uci)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise ValueError(f"Invalid UCI move format: '{move_uci}'. Please use standard format like 'e2e4' (start_square+end_square).")
|
|
90
|
+
|
|
91
|
+
if move not in game.board.legal_moves:
|
|
92
|
+
# Create a helpful list of some legal moves
|
|
93
|
+
sample_moves = ", ".join([str(m) for m in list(game.board.legal_moves)[:3]])
|
|
94
|
+
raise ValueError(f"Illegal move: '{move_uci}'. Review the board state. Sample legal moves: {sample_moves}...")
|
|
95
|
+
|
|
96
|
+
# Execute Move
|
|
97
|
+
game.board.push(move)
|
|
98
|
+
|
|
99
|
+
# Check Claim
|
|
100
|
+
if claim_win:
|
|
101
|
+
if not game.board.is_checkmate():
|
|
102
|
+
# Revert? No, usually valid move but false claim is just an error message,
|
|
103
|
+
# but spec said "Error - Failed Claim: Move rejected". So we should revert.
|
|
104
|
+
game.board.pop()
|
|
105
|
+
raise ValueError("Move rejected: You claimed Checkmate, but this move does not result in Checkmate.")
|
|
106
|
+
|
|
107
|
+
# Notify waiters (User or Agent waiting for this move)
|
|
108
|
+
game.move_event.set()
|
|
109
|
+
game.move_event.clear() # Reset for next turn
|
|
110
|
+
|
|
111
|
+
# If vs Computer and it's Computer's turn now (and game not over)
|
|
112
|
+
if game.config.get("type") == "computer" and not game.board.is_game_over():
|
|
113
|
+
# Trigger Background AI Move
|
|
114
|
+
# We use asyncio.create_task to run it without blocking the return of this function
|
|
115
|
+
asyncio.create_task(self._computer_turn(game))
|
|
116
|
+
|
|
117
|
+
return "Move accepted"
|
|
118
|
+
|
|
119
|
+
async def _computer_turn(self, game: GameInstance):
|
|
120
|
+
"""
|
|
121
|
+
Calculates and executes computer move.
|
|
122
|
+
"""
|
|
123
|
+
# Simulate thinking time?
|
|
124
|
+
await asyncio.sleep(0.5)
|
|
125
|
+
|
|
126
|
+
difficulty = game.config.get("difficulty", 5)
|
|
127
|
+
ai_move = game.ai.get_move(game.board, difficulty)
|
|
128
|
+
|
|
129
|
+
if ai_move:
|
|
130
|
+
game.board.push(ai_move)
|
|
131
|
+
# Notify waiters (Agent waiting for computer)
|
|
132
|
+
game.move_event.set()
|
|
133
|
+
game.move_event.clear()
|
|
134
|
+
|
src/mcp_server.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
import webbrowser
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, Literal
|
|
6
|
+
from mcp.server.fastmcp import FastMCP, Context
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
# We use relative imports assuming this is run as a module (python -m src.mcp_server)
|
|
10
|
+
# or ensure PYTHONPATH is set.
|
|
11
|
+
try:
|
|
12
|
+
from .game_state import GameManager
|
|
13
|
+
from .rendering import render_board_to_markdown, render_board_to_html
|
|
14
|
+
from .web_dashboard import start_dashboard
|
|
15
|
+
except ImportError:
|
|
16
|
+
# Fallback for direct execution if path not set
|
|
17
|
+
import sys
|
|
18
|
+
import os
|
|
19
|
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
20
|
+
from src.game_state import GameManager
|
|
21
|
+
from src.rendering import render_board_to_markdown, render_board_to_html
|
|
22
|
+
from src.web_dashboard import start_dashboard
|
|
23
|
+
|
|
24
|
+
# Initialize FastMCP
|
|
25
|
+
mcp = FastMCP("Chess Server")
|
|
26
|
+
manager = GameManager()
|
|
27
|
+
DASHBOARD_PORT = 8080
|
|
28
|
+
|
|
29
|
+
# --- Pydantic Models for Tools ---
|
|
30
|
+
|
|
31
|
+
class GameConfig(BaseModel):
|
|
32
|
+
type: Literal["computer", "agent"] = Field(..., description="Play against 'computer' (AI) or 'agent' (another tool/human)")
|
|
33
|
+
color: Literal["white", "black"] = Field("white", description="Your color. 'white' moves first. If 'black', computer will move first.")
|
|
34
|
+
showUi: bool = Field(False, description="If true, returns an interactive HTML board in waitForNextTurn. Required for human players.")
|
|
35
|
+
difficulty: int = Field(5, ge=1, le=10, description="AI Difficulty Level (1-10), if type is 'computer'.")
|
|
36
|
+
|
|
37
|
+
class WaitForTurnRequest(BaseModel):
|
|
38
|
+
game_id: str = Field(..., description="The ID of the active game.")
|
|
39
|
+
|
|
40
|
+
class FinishTurnRequest(BaseModel):
|
|
41
|
+
game_id: str = Field(..., description="The ID of the active game.")
|
|
42
|
+
move: str = Field(..., description="The move in UCI format (e.g., 'e2e4').")
|
|
43
|
+
claim_win: bool = Field(False, description="Set to true if you are claiming Checkmate or Win with this move.")
|
|
44
|
+
|
|
45
|
+
# --- Background Helper ---
|
|
46
|
+
|
|
47
|
+
def launch_dashboard_thread():
|
|
48
|
+
try:
|
|
49
|
+
start_dashboard(port=DASHBOARD_PORT)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f"Failed to start dashboard: {e}")
|
|
52
|
+
|
|
53
|
+
# --- Tools ---
|
|
54
|
+
|
|
55
|
+
@mcp.tool()
|
|
56
|
+
def createGame(config: GameConfig) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Initializes a new chess game session.
|
|
59
|
+
Returns the Game ID and instructions.
|
|
60
|
+
"""
|
|
61
|
+
# Convert Pydantic model to dict
|
|
62
|
+
game = manager.create_game(config.model_dump())
|
|
63
|
+
|
|
64
|
+
# Logic: If Player chose Black against Computer, Computer must move NOW (White).
|
|
65
|
+
first_move_msg = ""
|
|
66
|
+
if config.type == "computer" and config.color == "black":
|
|
67
|
+
# Trigger computer move immediately as it is White
|
|
68
|
+
# We can't await in sync tool? FastMCP tools can be sync or async.
|
|
69
|
+
# But create_game is sync in manager?
|
|
70
|
+
# We need to trigger it. Manager.make_move is async.
|
|
71
|
+
# But we can just trigger the background task manually.
|
|
72
|
+
|
|
73
|
+
# NOTE: We need to access the event loop or just run it synchronously?
|
|
74
|
+
# Better: Implementation in Manager to "start_computer_as_white".
|
|
75
|
+
# For now, let's use the background task approach if we have loop access.
|
|
76
|
+
# Simpler: Just set a flag or call a sync helper.
|
|
77
|
+
|
|
78
|
+
# HACK for Sync Tool spawning Async Task:
|
|
79
|
+
try:
|
|
80
|
+
loop = asyncio.get_running_loop()
|
|
81
|
+
loop.create_task(manager._computer_turn(game))
|
|
82
|
+
first_move_msg = " Computer (White) is making the first move..."
|
|
83
|
+
except RuntimeError:
|
|
84
|
+
# No running loop? FastMCP wraps sync tools?
|
|
85
|
+
# If createGame is sync, it might not have loop.
|
|
86
|
+
# But uvicorn runs loop.
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
info = (
|
|
90
|
+
f"Game Created Successfully!\n"
|
|
91
|
+
f"- Game ID: {game.id}\n"
|
|
92
|
+
f"- Type: {config.type}\n"
|
|
93
|
+
f"- You are: {config.color.title()}\n"
|
|
94
|
+
f"- Difficulty: Level {config.difficulty} (if computer)\n\n"
|
|
95
|
+
f"{first_move_msg}\n"
|
|
96
|
+
f"Next action: Call `waitForNextTurn(game_id='{game.id}')` to start."
|
|
97
|
+
)
|
|
98
|
+
return info
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
async def waitForNextTurn(game_id: str) -> list:
|
|
102
|
+
"""
|
|
103
|
+
Blocks until it is the Agent's turn (or User's turn via Agent proxy).
|
|
104
|
+
Waits up to 30 seconds for the opponent to move.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
- Board state (Markdown)
|
|
108
|
+
- UI Board (HTML) if showUi is true
|
|
109
|
+
- Or 'Timeout' message if no move happens.
|
|
110
|
+
"""
|
|
111
|
+
game = manager.get_game(game_id)
|
|
112
|
+
if not game:
|
|
113
|
+
return ["Error: Game not found"]
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Check if game over immediately
|
|
117
|
+
if game.is_game_over:
|
|
118
|
+
return [f"Game Over: {game.result}"]
|
|
119
|
+
|
|
120
|
+
# --- Side Logic ---
|
|
121
|
+
# If I am 'color', is it my turn?
|
|
122
|
+
my_color = chess.WHITE if game.config.get("color", "white") == "white" else chess.BLACK
|
|
123
|
+
is_my_turn = (game.board.turn == my_color)
|
|
124
|
+
|
|
125
|
+
# If it is NOT my turn, I should probably wait.
|
|
126
|
+
# Exception: If I am an "Agent" playing vs "Agent", maybe I am the OTHER agent?
|
|
127
|
+
# But config only has ONE color.
|
|
128
|
+
# Assumption: This tool is called by the "Owner" of the game session.
|
|
129
|
+
|
|
130
|
+
if not is_my_turn:
|
|
131
|
+
# It's opponent's turn.
|
|
132
|
+
# Wait for move event.
|
|
133
|
+
try:
|
|
134
|
+
await asyncio.wait_for(game.move_event.wait(), timeout=30.0)
|
|
135
|
+
except asyncio.TimeoutError:
|
|
136
|
+
return ["Timeout: No move received yet. Please call this tool again immediately."]
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
return [f"Error during wait: {str(e)}"]
|
|
140
|
+
|
|
141
|
+
# Generate Content
|
|
142
|
+
md = render_board_to_markdown(game.board.fen())
|
|
143
|
+
|
|
144
|
+
# Add Guidance
|
|
145
|
+
md += "\n\n**Next Action**: Decide your move and call `finishTurn(game_id, move)`."
|
|
146
|
+
|
|
147
|
+
content = []
|
|
148
|
+
content.append(md)
|
|
149
|
+
|
|
150
|
+
# 2. UI Resource
|
|
151
|
+
if game.config.get("showUi"):
|
|
152
|
+
# Pass perspective based on config
|
|
153
|
+
is_white = (game.config.get("color", "white") == "white")
|
|
154
|
+
html = render_board_to_html(game.board.fen(), game.id, is_white_perspective=is_white)
|
|
155
|
+
|
|
156
|
+
resource = {
|
|
157
|
+
"type": "resource",
|
|
158
|
+
"resource": {
|
|
159
|
+
"uri": f"ui://chess/{game.id}",
|
|
160
|
+
"mimeType": "text/html",
|
|
161
|
+
"text": html
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
content.append(resource)
|
|
165
|
+
|
|
166
|
+
return content
|
|
167
|
+
|
|
168
|
+
@mcp.tool()
|
|
169
|
+
async def finishTurn(game_id: str, move: str, claim_win: bool = False) -> str:
|
|
170
|
+
"""
|
|
171
|
+
Submits a move to the game server.
|
|
172
|
+
Arguments:
|
|
173
|
+
- game_id: ID of the game
|
|
174
|
+
- move: UCI format (e.g. e2e4)
|
|
175
|
+
- claim_win: Set to true to claim checkmate/win.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
result = await manager.make_move(game_id, move, claim_win)
|
|
179
|
+
|
|
180
|
+
# Check game over after move
|
|
181
|
+
game = manager.get_game(game_id)
|
|
182
|
+
if game and game.is_game_over:
|
|
183
|
+
return f"Move accepted. Game Over: {game.result}. No further actions needed."
|
|
184
|
+
|
|
185
|
+
return f"{result}\nNext action: Call `waitForNextTurn(game_id='{game_id}')` to wait for opponent's move."
|
|
186
|
+
except ValueError as e:
|
|
187
|
+
return f"Error: {str(e)}\nAdvice: Please review the error, check the board state in the previous turn, and try a different move."
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return f"System Error: {str(e)}"
|
|
190
|
+
|
|
191
|
+
# --- Entry Point ---
|
|
192
|
+
|
|
193
|
+
# --- Entry Point ---
|
|
194
|
+
|
|
195
|
+
def main():
|
|
196
|
+
"""
|
|
197
|
+
Main entry point for the Chess MCP Server.
|
|
198
|
+
"""
|
|
199
|
+
# Start Dashboard
|
|
200
|
+
t = threading.Thread(target=launch_dashboard_thread, daemon=True)
|
|
201
|
+
t.start()
|
|
202
|
+
|
|
203
|
+
# Open Browser (Best Effort)
|
|
204
|
+
try:
|
|
205
|
+
webbrowser.open(f"http://localhost:{DASHBOARD_PORT}")
|
|
206
|
+
except:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
print(f"Chess MCP Server Running. Dashboard at http://localhost:{DASHBOARD_PORT}")
|
|
210
|
+
|
|
211
|
+
# Run MCP
|
|
212
|
+
mcp.run(transport="stdio")
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
main()
|
src/rendering.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import chess
|
|
2
|
+
|
|
3
|
+
def render_board_to_markdown(fen: str) -> str:
|
|
4
|
+
"""
|
|
5
|
+
Converts a FEN string into a Markdown formatted table representation of the board.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
fen (str): The Forsyth-Edwards Notation string of the game state.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
str: A multi-line string containing the Markdown table.
|
|
12
|
+
"""
|
|
13
|
+
board = chess.Board(fen)
|
|
14
|
+
|
|
15
|
+
lines = []
|
|
16
|
+
lines.append("| Rank | a | b | c | d | e | f | g | h |")
|
|
17
|
+
lines.append("|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|")
|
|
18
|
+
|
|
19
|
+
# 8(rank 7) to 1(rank 0)
|
|
20
|
+
for rank in range(7, -1, -1):
|
|
21
|
+
row_content = f"| **{rank + 1}** |"
|
|
22
|
+
for file in range(8):
|
|
23
|
+
piece = board.piece_at(chess.square(file, rank))
|
|
24
|
+
symbol = piece.unicode_symbol() if piece else " "
|
|
25
|
+
row_content += f" {symbol} |"
|
|
26
|
+
lines.append(row_content)
|
|
27
|
+
|
|
28
|
+
# 5. Join lines to form the final string
|
|
29
|
+
output = "\n".join(lines)
|
|
30
|
+
|
|
31
|
+
# 6. Append State Info
|
|
32
|
+
turn_text = "White" if board.turn == chess.WHITE else "Black"
|
|
33
|
+
output += f"\n\n**Turn**: {turn_text} to move"
|
|
34
|
+
output += f"\n**FEN**: `{fen}`"
|
|
35
|
+
|
|
36
|
+
return output
|
|
37
|
+
|
|
38
|
+
def render_board_to_html(fen: str, game_id: str, is_white_perspective: bool = True) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Renders an interactive HTML chess board for MCP-UI.
|
|
41
|
+
Includes 'postMessage' logic for finishTurn.
|
|
42
|
+
"""
|
|
43
|
+
board = chess.Board(fen)
|
|
44
|
+
|
|
45
|
+
# Simple CSS/JS Board
|
|
46
|
+
# We will use chess.js and chessboard.js via CDN or a simple SVG approach?
|
|
47
|
+
# For a self-contained "resource", using a library via CDN is standard for web views.
|
|
48
|
+
# However, MCP UI in tools might be sandboxed without external network access depending on Host.
|
|
49
|
+
# Safe approach: Pure HTML/CSS Grid + Vanilla JS Drag Drop (Complex) OR use library.
|
|
50
|
+
# Let's use a robust library: Chessboard.js + Chess.js (CDN).
|
|
51
|
+
# If offline is required (as per mcp_reference_en hints), we might need to inline or stick to simple clicks.
|
|
52
|
+
|
|
53
|
+
# Let's implement a simple "Click to Move" table-based UI with JS,
|
|
54
|
+
# as it's dependency-free and follows the "Production-level... without internet connectivity" hint in reference.
|
|
55
|
+
|
|
56
|
+
display_turn = "White" if board.turn == chess.WHITE else "Black"
|
|
57
|
+
user_side = "White" if is_white_perspective else "Black"
|
|
58
|
+
|
|
59
|
+
html = f"""
|
|
60
|
+
<!DOCTYPE html>
|
|
61
|
+
<html>
|
|
62
|
+
<head>
|
|
63
|
+
<style>
|
|
64
|
+
body {{ font-family: sans-serif; display: flex; flex-direction: column; align-items: center; background-color: #2c2c2c; color: #f0f0f0; }}
|
|
65
|
+
h2 {{ margin-bottom: 5px; }}
|
|
66
|
+
.info {{ margin-bottom: 15px; text-align: center; }}
|
|
67
|
+
.board {{ display: grid; grid-template-columns: repeat(8, 45px); grid-template-rows: repeat(8, 45px); border: 5px solid #4a4a4a; }}
|
|
68
|
+
.square {{ width: 45px; height: 45px; display: flex; justify-content: center; align-items: center; font-size: 35px; cursor: pointer; user-select: none; }}
|
|
69
|
+
.white {{ background-color: #eeeed2; color: black; }}
|
|
70
|
+
.black {{ background-color: #769656; color: black; }}
|
|
71
|
+
.selected {{ background-color: #bbcb2b !important; }}
|
|
72
|
+
.controls {{ margin-top: 20px; display: flex; flex-direction: column; gap: 10px; background: #3c3c3c; padding: 15px; border-radius: 8px; }}
|
|
73
|
+
input, button {{ padding: 8px; border-radius: 4px; border: none; }}
|
|
74
|
+
button {{ background-color: #769656; color: white; cursor: pointer; font-weight: bold; }}
|
|
75
|
+
button:hover {{ background-color: #567636; }}
|
|
76
|
+
#status {{ margin-top: 10px; font-weight: bold; color: #ffd700; height: 20px; }}
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<h2>Chess MCP</h2>
|
|
81
|
+
<div class="info">
|
|
82
|
+
<div>Game ID: {game_id}</div>
|
|
83
|
+
<div style="font-size: 1.1em; margin-top: 5px;">You are: <strong>{user_side}</strong></div>
|
|
84
|
+
<div style="color: #aaa;">To Move: {display_turn}</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div id="board" class="board"></div>
|
|
88
|
+
|
|
89
|
+
<div class="controls">
|
|
90
|
+
<div>
|
|
91
|
+
<label>Move (UCI): <input type="text" id="uciInput" placeholder="e.g. e2e4"></label>
|
|
92
|
+
</div>
|
|
93
|
+
<div>
|
|
94
|
+
<label><input type="checkbox" id="chkClaimWin"> Claim Checkmate/Win</label>
|
|
95
|
+
</div>
|
|
96
|
+
<button onclick="submitMove()">Submit Move</button>
|
|
97
|
+
</div>
|
|
98
|
+
<div id="status"></div>
|
|
99
|
+
|
|
100
|
+
<script>
|
|
101
|
+
const fen = "{fen}";
|
|
102
|
+
const gameId = "{game_id}";
|
|
103
|
+
const isWhitePerspective = {'true' if is_white_perspective else 'false'};
|
|
104
|
+
|
|
105
|
+
function renderBoard() {{
|
|
106
|
+
const boardEl = document.getElementById('board');
|
|
107
|
+
boardEl.innerHTML = '';
|
|
108
|
+
|
|
109
|
+
const rows = fen.split(' ')[0].split('/');
|
|
110
|
+
|
|
111
|
+
// FEN rows are Rank 8 down to Rank 1
|
|
112
|
+
// If White Perspective: Render Row 0 (Rank 8) to Row 7 (Rank 1)
|
|
113
|
+
// If Black Perspective: Render Row 7 (Rank 1) to Row 0 (Rank 8) ?
|
|
114
|
+
// Actually, we just need to iterate the grid differently.
|
|
115
|
+
|
|
116
|
+
// Let's create a 2D array first for easier manipulation
|
|
117
|
+
let grid = [];
|
|
118
|
+
for (let r = 0; r < 8; r++) {{
|
|
119
|
+
let rowArr = [];
|
|
120
|
+
const rankStr = rows[r];
|
|
121
|
+
for (let i = 0; i < rankStr.length; i++) {{
|
|
122
|
+
const char = rankStr[i];
|
|
123
|
+
if (isNaN(char)) {{
|
|
124
|
+
rowArr.push(char);
|
|
125
|
+
}} else {{
|
|
126
|
+
const empties = parseInt(char);
|
|
127
|
+
for (let k = 0; k < empties; k++) {{
|
|
128
|
+
rowArr.push(null);
|
|
129
|
+
}}
|
|
130
|
+
}}
|
|
131
|
+
}}
|
|
132
|
+
grid.push(rowArr);
|
|
133
|
+
}}
|
|
134
|
+
|
|
135
|
+
// Render Loops
|
|
136
|
+
// White: r=0 (Rank 8) -> r=7 (Rank 1). c=0 (File a) -> c=7 (File h)
|
|
137
|
+
// Black: r=7 (Rank 1) -> r=0 (Rank 8). c=7 (File h) -> c=0 (File a)
|
|
138
|
+
|
|
139
|
+
const startR = isWhitePerspective ? 0 : 7;
|
|
140
|
+
const endR = isWhitePerspective ? 8 : -1;
|
|
141
|
+
const stepR = isWhitePerspective ? 1 : -1;
|
|
142
|
+
|
|
143
|
+
const startC = isWhitePerspective ? 0 : 7;
|
|
144
|
+
const endC = isWhitePerspective ? 8 : -1;
|
|
145
|
+
const stepC = isWhitePerspective ? 1 : -1;
|
|
146
|
+
|
|
147
|
+
for (let r = startR; r !== endR; r += stepR) {{
|
|
148
|
+
for (let c = startC; c !== endC; c += stepC) {{
|
|
149
|
+
createSquare(r, c, grid[r][c]);
|
|
150
|
+
}}
|
|
151
|
+
}}
|
|
152
|
+
}}
|
|
153
|
+
|
|
154
|
+
const pieceMap = {{
|
|
155
|
+
'r': 'โ', 'n': 'โ', 'b': 'โ', 'q': 'โ', 'k': 'โ', 'p': 'โ',
|
|
156
|
+
'R': 'โ', 'N': 'โ', 'B': 'โ', 'Q': 'โ', 'K': 'โ', 'P': 'โ'
|
|
157
|
+
}};
|
|
158
|
+
|
|
159
|
+
let selectedSq = null;
|
|
160
|
+
|
|
161
|
+
function createSquare(row, col, pieceChar) {{
|
|
162
|
+
const div = document.createElement('div');
|
|
163
|
+
// Color logic: (row+col)%2==0 is Light.
|
|
164
|
+
// But 'row' index from FEN (0=Rank8) matches standard odd/even check?
|
|
165
|
+
// Rank 8 (index 0) + File a (index 0) = 0 -> Light. Correct (a8 is light).
|
|
166
|
+
const isLight = (row + col) % 2 === 0;
|
|
167
|
+
div.className = 'square ' + (isLight ? 'white' : 'black');
|
|
168
|
+
|
|
169
|
+
// Store logical coordinates for click handling
|
|
170
|
+
div.dataset.row = row;
|
|
171
|
+
div.dataset.col = col;
|
|
172
|
+
|
|
173
|
+
if (pieceChar) {{
|
|
174
|
+
div.textContent = pieceMap[pieceChar] || pieceChar;
|
|
175
|
+
}}
|
|
176
|
+
|
|
177
|
+
div.onclick = () => onSquareClick(row, col, div);
|
|
178
|
+
document.getElementById('board').appendChild(div);
|
|
179
|
+
}}
|
|
180
|
+
|
|
181
|
+
function onSquareClick(row, col, divElement) {{
|
|
182
|
+
const file = String.fromCharCode(97 + col);
|
|
183
|
+
const rank = 8 - row;
|
|
184
|
+
const coord = file + rank;
|
|
185
|
+
|
|
186
|
+
// Clear previous selection highlight
|
|
187
|
+
document.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
|
|
188
|
+
|
|
189
|
+
if (!selectedSq) {{
|
|
190
|
+
// Select From
|
|
191
|
+
selectedSq = coord;
|
|
192
|
+
divElement.classList.add('selected');
|
|
193
|
+
document.getElementById('status').innerText = "Selected: " + coord;
|
|
194
|
+
}} else {{
|
|
195
|
+
// Select To
|
|
196
|
+
const move = selectedSq + coord;
|
|
197
|
+
document.getElementById('uciInput').value = move;
|
|
198
|
+
selectedSq = null; // Reset selection
|
|
199
|
+
document.getElementById('status').innerText = "Ready to submit: " + move;
|
|
200
|
+
}}
|
|
201
|
+
}}
|
|
202
|
+
|
|
203
|
+
function submitMove() {{
|
|
204
|
+
const move = document.getElementById('uciInput').value;
|
|
205
|
+
if (!move) {{
|
|
206
|
+
alert("Please enter or select a move");
|
|
207
|
+
return;
|
|
208
|
+
}}
|
|
209
|
+
|
|
210
|
+
const claimWin = document.getElementById('chkClaimWin').checked;
|
|
211
|
+
|
|
212
|
+
// Post Message to MCP Host
|
|
213
|
+
window.parent.postMessage({{
|
|
214
|
+
type: 'action',
|
|
215
|
+
action: 'finishTurn',
|
|
216
|
+
payload: {{
|
|
217
|
+
game_id: gameId,
|
|
218
|
+
move: move,
|
|
219
|
+
claim_win: claimWin
|
|
220
|
+
}}
|
|
221
|
+
}}, '*');
|
|
222
|
+
|
|
223
|
+
document.getElementById('status').innerText = "Submitted: " + move + "...";
|
|
224
|
+
// Disable button to prevent double submit
|
|
225
|
+
document.querySelector('button').disabled = true;
|
|
226
|
+
}}
|
|
227
|
+
|
|
228
|
+
renderBoard();
|
|
229
|
+
</script>
|
|
230
|
+
</body>
|
|
231
|
+
</html>
|
|
232
|
+
"""
|
|
233
|
+
return html
|
src/web_dashboard.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from fastapi import FastAPI, HTTPException
|
|
2
|
+
from fastapi.responses import HTMLResponse
|
|
3
|
+
import uvicorn
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import List
|
|
6
|
+
from .game_state import GameManager
|
|
7
|
+
from .rendering import render_board_to_html
|
|
8
|
+
|
|
9
|
+
app = FastAPI(title="Chess MCP Dashboard")
|
|
10
|
+
manager = GameManager()
|
|
11
|
+
|
|
12
|
+
@app.get("/", response_class=HTMLResponse)
|
|
13
|
+
async def index():
|
|
14
|
+
games = manager.list_games()
|
|
15
|
+
|
|
16
|
+
rows = ""
|
|
17
|
+
for g in games:
|
|
18
|
+
rows += f"""
|
|
19
|
+
<tr>
|
|
20
|
+
<td><a href="/game/{g['id']}">{g['id']}</a></td>
|
|
21
|
+
<td>{g['type']}</td>
|
|
22
|
+
<td>{g['turn']}</td>
|
|
23
|
+
<td>{g['fen']}</td>
|
|
24
|
+
</tr>
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
html = f"""
|
|
28
|
+
<html>
|
|
29
|
+
<head><title>Chess MCP Dashboard</title>
|
|
30
|
+
<style>table {{ width: 100%; border-collapse: collapse; }} th, td {{ border: 1px solid #ddd; padding: 8px; }} tr:nth-child(even){{background-color: #f2f2f2;}}</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<h1>Active Chess Games</h1>
|
|
34
|
+
<table>
|
|
35
|
+
<tr><th>ID</th><th>Type</th><th>Turn</th><th>FEN</th></tr>
|
|
36
|
+
{rows}
|
|
37
|
+
</table>
|
|
38
|
+
<br><a href="/" style="background:#eee; padding:5px;">Refresh</a>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
41
|
+
"""
|
|
42
|
+
return html
|
|
43
|
+
|
|
44
|
+
@app.get("/game/{game_id}", response_class=HTMLResponse)
|
|
45
|
+
async def view_game(game_id: str):
|
|
46
|
+
game = manager.get_game(game_id)
|
|
47
|
+
if not game:
|
|
48
|
+
raise HTTPException(status_code=404, detail="Game not found")
|
|
49
|
+
|
|
50
|
+
# We reuse the render logic but disable interaction for spectator?
|
|
51
|
+
# Or keep it interactive but actions won't work if not via MCP?
|
|
52
|
+
# Actually, the postMessage logic won't work in a normal browser (no parent).
|
|
53
|
+
# So we should render a read-only version or one that logs to console.
|
|
54
|
+
|
|
55
|
+
# For now, let's just reuse the HTML generator
|
|
56
|
+
html = render_board_to_html(game.board.fen(), game.id)
|
|
57
|
+
|
|
58
|
+
# Wrap in a back link
|
|
59
|
+
wrapper = f"""
|
|
60
|
+
<div><a href="/"><< Back to Dashboard</a></div>
|
|
61
|
+
{html}
|
|
62
|
+
"""
|
|
63
|
+
return wrapper
|
|
64
|
+
|
|
65
|
+
def start_dashboard(port=8080):
|
|
66
|
+
uvicorn.run(app, host="0.0.0.0", port=port, log_level="error")
|