dice-mcp-server
by guan-tends
README.md
# @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](https://github.com/guan-tends/mcp-ai) SimpleServer (HTTP transport, raw zod-shape schemas). Same composition-root pattern as `@guan-tends/matrix-mcp-server`.
## Quick Start
```bash
npm install
npm test # 154 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,
and tallies:
```json
{
"tallies": { "successes": 3, "opportunities": 1, "strife": 1, "explosive": 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).
| Param | Range | Default | Notes |
| ------------------------- | ----- | -------- | ------------------------------------------------------------------------------------------------ |
| `trait` | 1–10 | required | also the keep count |
| `skill` | 0–10 | 0 | 0 = untrained |
| `tn` | — | — | 5 trivial · 10 easy · 15 average · 20 difficult · 25 very hard · 30 extreme · 40 near-impossible |
| `raises` | 0–10 | 0 | +5 TN each; capped by `voidRing` |
| `freeRaises` | 0–10 | 0 | effect only, no TN, never counts vs cap |
| `voidRing` | 1–10 | — | caps declared raises |
| `emphasis` | bool | false | reroll initial 1s once, BEFORE explosions |
| `penalty` | int | 0 | wound/stance penalty, applied once to the total |
| `rollBonus` / `keepBonus` | 0–10 | 0 | Void Point = +1k1 |
Untrained (skill 0): trait dice only, ALL kept, no explosions, no raises
(raises on untrained rolls are rejected with an explanatory error).
### `l5r5_roll` — Ring & Skill dice (5e)
Rolls `Ring` d6 ring dice + `Skill` d12 skill dice, keeps `Ring`-rating
dice chosen AFTER seeing the pool.
| Param | Range | Default | Notes |
| ------------------------- | ------ | ------------- | ----------------------------------------------------------------------------------------------------------------- |
| `ring` | 1–5 | required | also the keep count |
| `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 |
| `advantage` | bool | false | convert non-explosive ring dice → skill dice |
| `disadvantage` | bool | false | convert worst skill die → ring die |
| `conversions` | string | — | explicit `"2:skill,4:ring"` (1-based, overrides adv/disadv) |
| `policy` | enum | success_first | `min_strife`, `max_opportunity` |
| `kept` | string | — | `"1,3"` explicit keep (1-based, exactly ring-count) |
| `composure` | int | — | advisory `composureExceeded` flag (kept strife ≥ value) |
| `includeExplosionBonuses` | bool | true | expand explosive faces into bonus dice |
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 add one bonus die of the same type — chained, capped,
marked `bonusFor`, never consuming keep slots.
## 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.
## 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).
## 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](https://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()` (removes the systemd-level loopback workaround).
- **Fate / other systems** — candidate engines behind the same tool
interface.
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityNo data
ResponsivenessNo issues