Skip to main content
Glama
sagelabs-dev

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)