dice-mcp-server
by sagelabs-dev
README.md
# @sagelabs/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 [@sagelabs/mcp-ai](https://github.com/sagelabs-dev/mcp-ai) SimpleServer (HTTP transport, raw zod-shape schemas). Same composition-root pattern as `@sagelabs/matrix-mcp-server`.
## Quick Start
**Zero-config (stdio — most MCP clients, same as passgen):**
```json
{
"mcpServers": {
"dice": {
"command": "npx",
"args": ["-y", "@sagelabs/dice-mcp-server"]
}
}
}
```
**HTTP (daemon style):**
```bash
npm install
npm test # 168 tests
npm start # serves MCP HTTP on 127.0.0.1:3777
```
Point any MCP client at `http://localhost:3777/` (Streamable HTTP transport).
### Example: a d20 attack roll with advantage
```json
{
"name": "dice_roll",
"arguments": { "expression": "2d20kl1+5", "dc": 15 }
}
```
```json
{
"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)
```json
{
"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)
```json
{
"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):
```json
{
"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): initiative `1k4`
(Insight k Reflexes — kept > rolled is legal), melee damage `6k2`,
Honor rolls `6k6`, spell casting `3k2`, unarmed `3k1`
| 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 engine
```
Error 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. `dc` is
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).
```bash
DICE_MCP_TRANSPORT=stdio node bin/dice-mcp-server.mjs # stdio
DICE_MCP_TRANSPORT=http node bin/dice-mcp-server.mjs # http on :3777
```
stdio 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).
```ini
[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.target
```
### Loopback 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):
```json
{ "id": "dice", "connection": { "type": "http", "url": "http://localhost:3777" } }
```
Then restart the gateway.
## Testing
```bash
npm test # 154 tests: engines, tools, e2e, distribution
npm run test:coverage # with coverage
npm run lint # eslint
npm run format:check # prettier
```
Test layers:
1. **Engine units** — deterministic via sequence RNG; every rule path
(explosions, emphasis ordering, untrained, raises/void, policies,
conversions, overrides) asserted exactly.
2. **Tool units** — schema shape contract (raw zod shapes), happy paths,
engine-error surfacing.
3. **E2E** — real MCP client over Streamable HTTP: initialize →
listTools → callTool for every tool, both error layers.
4. **Distribution sanity** — real CSPRNG with derived constants
(geometric-series explosion means, binomial success expectations).
## Sponsors
If dice-mcp-server is useful to you, consider supporting its continued development:
- **[GitHub Sponsors](https://github.com/sponsors/guan-tends)**
- **Bitcoin:** `bc1q0gd3mwjg3zy9sghv22kmpg823vss4c0zzdzg24`
- **Solana:** `Eu8wQcW68TKMs1a6eqzZu8znzU52QLqQugAMG8uCD6y6`
- **Ethereum / EVM:** `0x2733ff7c865C56d565a99BE1DC11B81cc76850A5`
- **XRP Ledger:** `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 @sagelabs/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](LICENSE).
---
Crafted with ❤️ by [Sage Labs](https://sagelabs.dev)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues