scopa-mcp-server
by Seboostian02
README.md
# Scopa MCP Server
A Model Context Protocol (MCP) stdio server that manages the state for
a two-player (or 2–4 player) game of Italian **Scopa**, with an
append-only Redis event log, low-latency `wait` synchronisation via
Redis Pub/Sub, a live spectator mode, an LLM opponent driven by the
Anthropic SDK, and a read-only web view.
Each player runs as its own `python main.py --game=<id> --player=<A|B|C|D>`
process. The processes do not talk to each other directly — they
coordinate through an event log in Redis. Game state is fully
reconstructable by replaying the log, so any process can be killed and
restarted without losing the game.
## Rules implemented
Neapolitan deck (40 cards, values 1–10 with Knave=8, Knight=9, King=10):
- Player A leads every round.
- Initial deal of 3 cards per player + 4 on the table. If the would-be
table contains three or more Kings, the shuffle is discarded and a
new one is drawn.
- **Forced capture**: you cannot trail a card if any capture is
possible.
- **Single-match priority**: if a single table card matches the played
card's value, you cannot take a multi-card subset that sums to it.
- **Scopa** (clearing the table) gives +1 point, except on the very
last play of the round.
- Round end: any cards left on the table go to the last player who
captured. Round scoring awards 1 point each for most-cards,
most-coins, settebello (7 of Coins), and primiera, plus the scopa
points accrued during the round.
- First player to reach 11 wins the game. Ties at the top force
another round.
## Architecture
```
Player A process Player B process Spectator process
(python main.py (python main.py (python main.py
--game=g --player=A) --game=g --player=B) --game=g --spectate)
│ │ │
└─────────────┬───────────┴────────────┬─────────────┘
│ │
Redis LIST Redis Pub/Sub
scopa:{g}:events scopa:{g}:events
(append-only event log) (push seq numbers)
```
**Single source of truth = the event log.** The engine
(`scopa/engine.py`) is a pure state machine: given a fresh
`ScopaGame()` and the full list of events, it always reaches the same
state. There are only two event types:
1. **shuffle** — payload carries the full 40-card deck order; the
engine deals from it and discards if ≥3 Kings land on the table.
2. **play** — payload carries `player`, `card_index` (1-based into the
hand at the time of the move) and `capture` (1-based table
indices). The engine applies the play, handles mid-round refills,
and runs end-of-round scoring from these alone.
Concurrency:
- **Turn ordering** is derived from the event log; a process refuses
to append a `play` unless its replayed state says it's this player's
turn.
- **Shuffle lock**: at the start of each round, only one process must
generate the shuffle. `RedisStore.acquire_shuffle_lock` uses
`SET NX EX`; the loser sleeps briefly and resyncs.
- **`wait` is push-based**: the tool subscribes to the pub/sub channel
and returns the instant the other player appends an event. No
polling.
- **Spectator** runs the same replay+subscribe loop but is read-only.
## Running it
### 1. Redis (local Docker is easiest on Windows)
```bash
docker run -d -p 6379:6379 --name scopa-redis redis:alpine
```
### 2. Python deps
```bash
python -m venv .venv
source .venv/Scripts/activate # or .venv/bin/activate on macOS/Linux
pip install -e . redis aiohttp python-dotenv
```
Requires Python 3.11+.
### 3. Two-player greedy test
```bash
bash run_test.sh
```
Spawns two MCP subprocesses, plays a full game with a deterministic
greedy strategy, and prints every status read and every play.
### 4. Wait-driven test with a live spectator
```bash
bash run_wait_test.sh
```
Two players run as concurrent asyncio tasks, each blocked on the
`wait` tool until it's their turn. A third subprocess joins in
`--spectate` mode and writes a live snapshot log to
`spectator_<game>.log`.
### 5. LLM opponent (Claude Haiku)
```bash
# put your key in .env first: echo 'ANTHROPIC_API_KEY=sk-...' > .env
# terminal 1
python play_greedy.py --game=llm_demo --player=A
# terminal 2
python python/chat.py --game-id=llm_demo
# or: bash llm_play.sh llm_demo
```
The LLM is driven through the Anthropic SDK's native tool-use
protocol. `python/chat.py` spawns an MCP subprocess for player B,
converts the MCP tool list to the Anthropic tool schema, and then
loops: `wait` → `status` → `play`. It does not use any LangChain /
LangGraph layer.
### 6. 3–4 players
```bash
python test_multi.py -n 4
```
The engine is generalised over an N-player tuple (2–4). `main.py`
takes `--players=A,B,C,D` on the first process to kick off a
multi-player game; later processes pick the player list up from the
Redis meta hash.
### 7. Read-only web view
```bash
python main.py --game=web_demo --player=A --web
# → http://127.0.0.1:19000/ (port = 19000 + offset(player))
```
Meta-refreshing HTML that shows the current hand, the table, the
per-player scores, and the most recent move, as seen from the running
player's seat. Deliberately bare-bones: the MCP `status` tool is still
the authoritative read path.
## MCP tools exposed by `main.py`
| Tool | Purpose |
|---|---|
| `status` | Returns the human-readable view of the hand, the table, the scores and whose turn it is. |
| `play` | Play a card. `card` (1-based hand index) and `capture` (list of 1-based table indices; empty to trail). |
| `wait` | Block until it is this player's turn. Returns the most recent opponent move. |
| `spectate` | Full move history so far (available to both players and `--spectate` processes). |
## Event log inspection
You can read the raw log of any running game directly from Redis:
```bash
docker exec -it scopa-redis redis-cli LRANGE 'scopa:<game_id>:events' 0 -1
```
## Files
| Path | Role |
|------|------|
| `scopa/cards.py` | Neapolitan deck and primiera scoring table |
| `scopa/engine.py` | Pure Scopa state machine driven by events |
| `scopa/store.py` | Async Redis event store with pub/sub and shuffle lock |
| `scopa/web.py` | Read-only aiohttp view |
| `main.py` | MCP stdio server + argument parsing |
| `test.py` / `run_test.sh` | Two-player greedy test runner |
| `test_wait.py` / `run_wait_test.sh` | Wait-driven two-player test + live spectator |
| `test_multi.py` | 3–4 player greedy test runner |
| `play_greedy.py` | Single-player greedy driver (useful as the opponent when the other player is the LLM) |
| `python/chat.py` / `llm_play.sh` | Claude-driven LLM opponent over the Anthropic SDK |
## Known limitations
- Redis persistence is disabled by default; stopping the container
drops the game state. That is intentional for demos.
- The greedy test player is deliberately unsophisticated — it is a
harness for the server, not a strong opponent.
- The web view uses 2s meta-refresh rather than WebSockets; this keeps
the server simple and well-behaved on Windows.
- No pytest suite per se — the three end-to-end test scripts are the
regression coverage for the engine and the MCP server. A proper
unit-test suite for the capture/scopa/primiera edge cases would be a
good next step.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues