@guan-tends/dice-mcp-server
v1.4.1
Published
Stateless dice engine MCP tool server - generic d20-style notation, L5R 4e Roll & Keep, and L5R 5e ring/skill symbol dice, via the Model Context Protocol.
Maintainers
Readme
@guan-tends/dice-mcp-server
Stateless dice engine MCP tool server. Pure dice math — no character sheets, no sessions, no persistence.
Three tools via the Model Context Protocol:
| Tool | System | Character |
| ----------- | -------------------------------------------------------- | ---------------------------------- |
| dice_roll | Generic d20-style notation | expression in, number out |
| l5r4_roll | Legend of the Five Rings 4e Roll & Keep (XkY+Z) | structured in, number + raises out |
| l5r5_roll | Legend of the Five Rings 5e (FFG) ring/skill symbol dice | structured in, symbols out |
Built on @guan-tends/mcp-ai SimpleServer (HTTP transport, raw zod-shape schemas). Same composition-root pattern as @guan-tends/matrix-mcp-server.
Quick Start
Zero-config (stdio — most MCP clients, same as passgen):
{
"mcpServers": {
"dice": {
"command": "npx",
"args": ["-y", "@guan-tends/dice-mcp-server"]
}
}
}HTTP (daemon style):
npm install
npm test # 168 tests
npm start # serves MCP HTTP on 127.0.0.1:3777Point any MCP client at http://localhost:3777/ (Streamable HTTP transport).
Example: a d20 attack roll with advantage
{
"name": "dice_roll",
"arguments": { "expression": "2d20kl1+5", "dc": 15 }
}{
"success": true,
"expression": "2d20kl1+5",
"terms": [
{
"notation": "2d20kl1",
"rolls": [
{ "face": 17, "chain": [], "final": 17, "rerolled": false },
{ "face": 8, "chain": [], "final": 8, "rerolled": false }
],
"kept": [{ "face": 8, "chain": [], "final": 8, "rerolled": false }],
"dropped": [{ "face": 17, "chain": [], "final": 17, "rerolled": false }],
"subtotal": 13
}
],
"total": 18,
"dc": 15,
"success": true
}Example: a 4e katana strike (Agility 3, Kenjutsu 4, emphasis, Void spent)
{
"name": "l5r4_roll",
"arguments": {
"trait": 3,
"skill": 4,
"tn": 25,
"emphasis": true,
"rollBonus": 1,
"keepBonus": 1,
"label": "Katana strike"
}
}Returns 7d10k4 — pool, kept/dropped dice with explosion chains, totals,
TN verdict, and a notes[] array explaining every rule that fired. A
failed roll with raises carries wouldSucceedWithoutRaises: true — the
GM-narration hook ("he'd have made it, but the flourish cost him").
Example: a 5e Fire check (Fire 3, Tactics 2, advantage)
{
"name": "l5r5_roll",
"arguments": { "ring": 3, "skill": 2, "tn": 3, "advantage": true }
}Returns the full pool with per-die symbols, kept indices, conversions, tallies, and the derived totals (⚑ + 🔥 = totalSuccesses):
{
"tallies": { "successes": 3, "opportunities": 1, "strife": 1, "explosive": 2 },
"totalSuccesses": 5,
"bonusSuccesses": 2,
"success": true,
"composureExceeded": false
}Tool Reference
dice_roll — generic notation
2d6+3 sum of 2d6 plus 3
d20 one d20
4d6kh3 keep highest 3 of 4d6 (ability scores)
2d20kl1 keep lowest 1 (disadvantage)
1d10! exploding: reroll-and-sum while max faces appear
1d12!11,12 exploding on specific faces (5e skill die: 11 and 12)
3d6r1 reroll initial 1s once (emphasis shape)
2d6+1d4+2 multi-term| Param | Type | Notes |
| ------------ | ------------- | --------------------------------------------------- |
| expression | string | required; whitespace-tolerant, case-insensitive |
| dc | int, optional | adds dc + success (total >= dc) to the result |
Parse errors are position-aware (... at position 4) so callers can fix
their own syntax. Doubled signs (2d6--3) and signless term
concatenation (2d61d4) are rejected with named errors.
l5r4_roll — Roll & Keep (4e)
Pools (Trait + Skill) d10s, keeps the highest Trait. D10s explode on
10s (trained rolls only). Every pool passes through the Ten Dice Rule:
no more than 10 rolled / 10 kept dice — excess kept dice add +2 each,
excess rolled dice convert 2:1 into kept dice (while kept < 10), and any
leftover rolled dice add +2 each. preCapPool and overflowBonus are
reported for audit.
Build a pool two ways:
- Trait + Skill (skill rolls):
(Trait + Skill) k Trait - Direct
rolled/kept(everything else): initiative1k4(Insight k Reflexes — kept > rolled is legal), melee damage6k2, Honor rolls6k6, spell casting3k2, unarmed3k1
| Param | Range | Default | Notes |
| ------------------------- | -------------- | ------- | ------------------------------------------------------------------------------------------------ |
| trait | 1–10 | — | also the keep count; omit for direct pools |
| skill | 0–10 | — | 0 (with no rollType/untrained) = unskilled |
| rolled / kept | 1–50 | — | direct pool input; overrides trait/skill |
| rollType | enum | skill | skill · trait · ring (explode + raises legal) · unskilled (neither) · custom |
| untrained | bool | — | explicit flag; false with skill 0 = Trait roll |
| tn | — | — | 5 trivial · 10 easy · 15 average · 20 difficult · 25 very hard · 30 extreme · 40 near-impossible |
| raises | 0–10 | 0 | +5 effective TN each; capped by voidRing |
| freeRaises | 0–10 | 0 | effect only, no TN, never counts vs cap |
| voidRing | 1–10 | — | caps declared raises |
| voidPoint | bool | false | spend a Void Point: +1k1 to the pool |
| emphasis | bool | false | reroll initial 1s once, BEFORE explosions; trained rolls only |
| penalty | int | 0 | wound/stance penalty — raises the effective TN, never the total (Nicked +3 … Down +40) |
| rollBonus / keepBonus | −10–10 | 0 | dice bonuses/penalties; penalties clamp kept ≤ rolled |
| totalBonus | int | 0 | flat bonus to the kept sum (Honor Rank on Fear rolls) |
| keepMode | highest/lowest | highest | lowest = deliberate failure |
| explodeOn | string | "10" | 9 (weapon mastery) · "9,10" · none (thrown weapons) |
Unskilled rolls (no skill ranks): trait dice only, ALL kept, no
explosions, no raises, no emphasis (raises/emphasis on unskilled rolls
are rejected or ignored with an explanatory note). Trait rolls are a
separate thing — rollType: "trait" explodes and allows raises.
l5r5_roll — Ring & Skill dice (5e)
Implements the corebook check pipeline (pp. 20–26, spec of record): assemble the pool (Step 3) → modify rolled dice (Step 4) → choose kept dice (Step 5) → resolve symbols on KEPT dice (Step 6).
The law, as implemented:
- Total successes = ⚑ + 🔥 (p. 24: "the sum total of success and explosive success symbols"). A kept skill-12 (pure explosive face) counts as one success; a kept ring-6 counts as two.
- Keep 1..ring dice (p. 24: "at least one... up to the value of the ring"; +1 per assisting character, p. 26). Under-keeping is legal and reported in notes.
- Explosions resolve post-keep, from kept dice only (Step 6.1): each 🔥 on a kept die rolls one bonus die of the same type; a kept bonus die's own 🔥 chains.
- Advantage + disadvantage cancel (p. 24 consolidate rule) — both flags together have no effect, per the book.
- Assistance (p. 26): +1 skill die per skilled helper, +1 ring die per unskilled helper.
| Param | Range | Default | Notes |
| -------------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------- |
| ring | 1–5 | required | also the keep maximum (before assistance) |
| skill | 0–5 | 0 | 0 = untrained (ring dice only) |
| tn | 1–10 | — | successes needed: 1 easy · 2 average · 3 difficult · 4 very hard · 5 extremely hard · 6 extraordinary · 7+ heroic |
| assistants | object | — | {skilled, unskilled} (p. 26); keep max +1 per assistant |
| keepCount | 1..keepMax | keepMax | under-keep via policy (book-legal); values above max clamp with a note |
| kept | string | — | "1,3" explicit keep (1-based, 1..keepMax — under-keeping legal) |
| bonusDice | enum | auto_keep | auto_keep (tallied) · auto_drop (shown, not tallied) · manual (pending — caller decides from the audit array) |
| conversions | string | — | explicit "2:skill,4:ring" (1-based, book-accurate surface; overrides adv/disadv) |
| advantage | bool | false | house simplification of named categories; cancels against disadvantage (p. 24) |
| disadvantage | bool | false | house simplification; cancels against advantage |
| policy | enum | success_first | min_strife, max_opportunity (select keepCount-many dice) |
| composure | int | — | advisory composureExceeded flag (kept strife ≥ value) |
| label | string | — | echoed in the result |
Deprecated: includeExplosionBonuses (bool) maps to bonusDice
(true → auto_keep, false → auto_drop); cannot be combined with it.
Result contract (the result is a full transcript — every automated
decision is visible): base pool with per-die symbols · kept /
dropped · bonusDice[] audit (sourceDieIndex, type, face, symbols,
chainDepth, disposition kept/dropped/pending) · conversions[] ·
tallies (raw ⚑/⧫/⏳/🔥 counts; bonusPending appears in manual
mode only) · totalSuccesses (⚑+🔥) · vs tn: success,
bonusSuccesses, shortfall · keepMax, requestedKeepCount,
keptIndices · explosiveTriggers, untrained · notes[] narrating
every automated decision.
House safety valve (disclosed, not book law): MAX_BONUS_CHAIN = 10
caps per-chain bonus-die depth — the book is naturally finite because a
player may always drop, but auto_keep automation needs a guard; the
cap firing is reported in notes.
Verified symbol charts (cross-checked against two independent 5e references):
| Ring d6 | 1 | 2 | 3 | 4 | 5 | 6 | | ------- | --- | ---------- | --- | ----------- | ---- | -------------------- | | symbols | — | opp+strife | opp | succ+strife | succ | succ+strife+expl |
| Skill d12 | 1–2 | 3–5 | 6–7 | 8–9 | 10 | 11 | 12 | | --------- | --- | --- | ----------- | ---- | -------- | -------------------- | -------------------- | | symbols | — | opp | succ+strife | succ | succ+opp | succ+strife+expl | expl (no strife) |
Explosive faces on KEPT dice add one bonus die of the same type after
keep selection (book Step 6.1) — chained, capped, fully audited in the
bonusDice[] array. Kept bonus dice are tallied (their symbols count
toward the TN, per the Sakura worked example, p. 23).
Architecture
Functional core, imperative shell. All randomness is constructor-injected
(rng(sides) => int in [1, sides]): production uses crypto.randomInt
(unbiased CSPRNG); tests inject sequence RNGs, making every engine test
deterministic. Symbol tables are data — adding Genesys/Star Wars dice
later is a table entry, not a rewrite.
src/
├── index.js bootstrap only (lifecycle, graceful shutdown)
├── config.js config layers (defaults ← JSON5 ← env) + validation
├── mcp-server.js tool definitions + SimpleServer wiring
└── engine/
├── rng.js createCryptoRng / createSequenceRng
├── core.js rollDice: explode-on-faces, reroll, caps
├── notation.js expression parser (position-aware errors)
├── d20.js expression evaluator
├── symbols.js 5e symbol tables (data)
├── l5r4.js Roll & Keep engine
└── l5r5.js ring/skill symbol engineError rejection is two-layer: zod-schema violations (out-of-range params)
are rejected at the MCP protocol layer before the tool body runs;
cross-field rule violations (e.g. raises > Void Ring) surface as
isError JSON results with the engine's human-readable message.
Design Decisions
- Stateless. No sheets, no sessions, no persistence. The caller is the GM brain; the server is dice math. Restart-safe by construction.
- Teaching descriptions. Every tool description embeds its system's TN scale and worked examples — an LLM caller learns the system from the tool itself.
- Deterministic tests. Sequence RNG injection makes explosion chains and keep decisions exactly reproducible; distribution tests with derived constants cover the real CSPRNG.
- Dice-as-data. 5e symbol tables are frozen data; new symbol systems are new tables, not new engines.
- No array-typed tool params. Kept indices, conversions, and explode faces are comma-strings — an MCP-gateway compatibility choice.
- Interpretation notes (parameterized, documented, adjustable if a
table ruling differs): 5e untrained = ring dice only; advantage
converts pre-keep and never sacrifices an explosive ring die;
composure is an explicit input with an advisory flag (no hardcoded
formula); 4e wound penalties apply once to the total.
dcis permissive (any integer) — permissive inputs, strict dice math.
Transports
| Transport | How | Default for |
| --------- | -------------------------------- | ----------------------------------------------- |
| stdio | JSON-RPC over stdin/stdout pipes | the dice-mcp-server bin (npx) |
| http | Streamable HTTP | the library entry (src/index.js, npm start) |
| sse | Server-Sent Events | opt-in via env |
Selection precedence (highest wins): explicit transport option →
DICE_MCP_TRANSPORT env (stdio|http|sse) → JSON5 config file
transport key → entry default (bin: stdio, library: http).
DICE_MCP_TRANSPORT=stdio node bin/dice-mcp-server.mjs # stdio
DICE_MCP_TRANSPORT=http node bin/dice-mcp-server.mjs # http on :3777stdio mode writes diagnostics to stderr only — stdout carries
exclusively the MCP protocol. An explicit DICE_MCP_TRANSPORT (or
config-file transport) always beats the entry default.
Deployment
Systemd unit (example)
Adjust paths/user for your host. If you use nvm, point PATH and
ExecStart at your node install (or source nvm in an ExecStartPre).
[Unit]
Description=Dice MCP Server
After=network.target
[Service]
Type=simple
User=YOURUSER
WorkingDirectory=/opt/dice-mcp-server
ExecStart=/usr/bin/node src/index.js
Restart=on-failure
RestartSec=5
# Loopback-only enforcement (see below)
IPAddressDeny=any
IPAddressAllow=localhost
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetLoopback enforcement
mcp-ai SimpleServer's express listen() binds all interfaces and
ignores the host config field. Loopback-only is therefore enforced at
the deployment layer — systemd IPAddressDeny=any +
IPAddressAllow=localhost (cgroup packet filter) with UFW default-deny.
Until mcp-ai supports a server-side bind host, deploy with both
controls. (The port config IS honored.)
Gateway wiring (mcp-ai aggregator)
Add to the aggregator's mcps array (back up config.json first):
{ "id": "dice", "connection": { "type": "http", "url": "http://localhost:3777" } }Then restart the gateway.
Testing
npm test # 154 tests: engines, tools, e2e, distribution
npm run test:coverage # with coverage
npm run lint # eslint
npm run format:check # prettierTest layers:
- Engine units — deterministic via sequence RNG; every rule path (explosions, emphasis ordering, untrained, raises/void, policies, conversions, overrides) asserted exactly.
- Tool units — schema shape contract (raw zod shapes), happy paths, engine-error surfacing.
- E2E — real MCP client over Streamable HTTP: initialize → listTools → callTool for every tool, both error layers.
- Distribution sanity — real CSPRNG with derived constants (geometric-series explosion means, binomial success expectations).
Support
If this server powers your table or your agents, tips are appreciated — they fund compute, inference, and the rest of the tool fleet:
- GitHub Sponsors: github.com/sponsors/guan-tends
- Solana:
Eu8wQcW68TKMs1a6eqzZu8znzU52QLqQugAMG8uCD6y6 - EVM:
0x2733ff7c865C56d565a99BE1DC11B81cc76850A5 - XRP:
r4X6e7McAQj7e8vBCeued1RYu4mCJrREDG
Roadmap
- Genesys / Star Wars dice — new symbol tables (data), same engine.
- ~~mcp-ai upstream: server-side bind-host support for express
listen()~~ — DONE upstream in @guan-tends/mcp-ai 1.6.7-guan.0 (consumed here via the dependency bump); systemd loopback enforcement retained as defense-in-depth. - Fate / other systems — candidate engines behind the same tool interface.
License
MIT — see LICENSE.
