Read chess positions by the rules: legality with its evidence, draws, facts, openings, PGN
Esca is the anglerfish's lure — the light that shows what is really on the board.
Rust/Python MIT chess library: rules, facts, explanations, PGN, opening books and names, UCI client, and an MCP server over it; one API, Chess960 throughout.
Position is placement and state and nothing else. Rules live in Variant implementations —
Classic and Chess960 — so a position answers a rules question by taking the variant that
defines it, and a new variant is a new implementation and nothing else. A Game pairs a variant
with the moves played, which is what repetition and claimable draws need. Facts answers what is
true about one position — 221 named facts in 14 groups — and annotated_moves() answers what each
of its legal moves does, with 27 more. Every fact is typed, named after what a player would call
it, and told about White and Black by name.
[dependencies]
esca = "0.4"
use esca::{Colour, Game, classic};
let mut game = Game::new(classic()); // Chess960 rules: `esca::chess960()`
game.play_san("e4").unwrap();
game.play_uci("e7e5").unwrap();
println!("{}", game.position().fen());
let facts = game.facts();
println!("{}", facts.tactics.legal_move_count.white);
println!("{:?}", facts.pawns.passed.of(Colour::Black).files());
println!("{}", facts.summary());
Cargo features, none on by default: lichess (streaming reader for the Lichess evaluation
dump), pgn (reading and writing games as PGN), polyglot (opening books), openings (the
bundled ECO catalogue), serde (the one JSON form of the facts, and the JSON Schema for it),
tensors (a run of positions as one typed array per fact) and python (the PyO3 module the
wheel is built from). Position::polyglot_key needs no feature.
pip install esca
import esca
game = esca.Game() # Chess960 rules: esca.Game(variant=esca.CHESS960)
game.play_san("e4")
game.play("e7e5")
print(game.position.fen)
facts = game.facts()
print(facts.tactics.legal_move_count.white)
print(list(facts.pawns.passed.black.files))
print(facts.to_dict()["material"]) # every group in the one JSON form
Wheels are abi3 for Python 3.12 and up. pip install esca[tensors] adds NumPy and
esca.tensors, which turns a run of positions into one typed array per fact.
Three short programs a side, reading the same examples/games.pgn, in
examples/ and
python/examples/:
pgn_report / read_games.py — per game of a PGN file: opening, final position, ending, passers.why_illegal / legal_moves.py — every legal move and what it does, then why one other is not.engine_game / engine_game.py — a UCI engine against itself, its ending as English, JSON and
arrays. Takes the engine's path; without one it says so and stops.Variant trait.KQkq and the AHah of X-FEN and Shredder-FEN alike, and writing KQkq
whenever the rook files allow it.MoveList that never allocates.Facts: fourteen groups of cheap facts about one position — the board itself, game state,
history, material, pawns, pieces, king, mobility, attacks, exchanges, threats, one-ply tactics,
endgame and the attack maps side by side — and MoveFacts for every legal move, from
annotated_moves(). Every value that differs between the two sides is a ByColour, read as
.white, .black or .of(colour).docs/features.md, docs/facts.schema.json, the Python type stubs and the tensor layout are
all generated from, and which the MCP server serves.serde::Serialize and by Python's to_dict(),
byte for byte the same and described by docs/facts.schema.json.Position, books read, drawn from and
built, and an ECO code and name for some 3,800 named positions.describe()
beside every value the explanations layer answers with.mcp/ is a second distribution from this repository: chess-esca-mcp, an MCP server that hands
esca's answers to an LLM as JSON — the whole state of a position, whether a move is legal and
every reason it is not, the named facts, the ECO name, opening-book moves, and PGN read and
written. It carries no engine and does no search. It runs as uvx chess-esca-mcp, is versioned
with the library and pins the matching esca, and is documented in
mcp/README.md.
docs/esca-api.md —
the API in both languages; §11 is the whole Python surface.docs/features.md —
every fact, its type and its meaning, group by group.docs/esca-vocabulary.md —
the terms the API and the facts are named after.chess-esca-mcp ships with a skill that teaches an agent which of its tools
answers which question.MIT — see LICENSE.
lichess reader streams, the game
database, and lichess-org/chess-openings,
whose opening names the openings feature bundles (CC0 1.0 Universal Public Domain
Dedication).Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
uvx chess-esca-mcpMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-anglerfishchess-chess-esca-mcp": {
"command": "uvx",
"args": [
"chess-esca-mcp"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referenceChess Esca works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.