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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chess-mcp-server = src.mcp_server:main
@@ -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")