#!/usr/bin/env python3
"""
Dice Roller MCP Server - Roll dice, flip coins, and handle all DnD/tabletop mechanics.
"""
import sys
import random
import logging
from mcp.server.fastmcp import FastMCP
# Configure logging to stderr
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
stream=sys.stderr
)
logger = logging.getLogger("dice-roller-server")
# Initialize MCP server
mcp = FastMCP("dice-roller")
# Dice face emoji map for visual flair
DICE_FACES = {
1: "⚀", 2: "⚁", 3: "⚂", 4: "⚃", 5: "⚄", 6: "⚅"
}
def parse_dice_notation(notation: str):
"""Parse NdM+K notation, returns (count, sides, modifier)."""
notation = notation.strip().lower().replace(" ", "")
modifier = 0
if "+" in notation:
parts = notation.split("+", 1)
notation = parts[0]
try:
modifier = int(parts[1])
except ValueError:
return None, None, None
elif "-" in notation and "d" in notation and notation.index("-") > notation.index("d"):
parts = notation.split("-", 1)
notation = parts[0]
try:
modifier = -int(parts[1])
except ValueError:
return None, None, None
if "d" not in notation:
return None, None, None
parts = notation.split("d", 1)
try:
count = int(parts[0]) if parts[0] else 1
sides = int(parts[1])
except ValueError:
return None, None, None
return count, sides, modifier
@mcp.tool()
async def flip_coin(times: str = "1") -> str:
"""Flip one or more coins and show results."""
logger.info(f"flip_coin called with times={times}")
try:
n = int(times.strip()) if times.strip() else 1
if n < 1 or n > 100:
return "❌ Error: Please flip between 1 and 100 coins."
results = [random.choice(["Heads", "Tails"]) for _ in range(n)]
heads = results.count("Heads")
tails = results.count("Tails")
if n == 1:
emoji = "🪙 Heads!" if results[0] == "Heads" else "🪙 Tails!"
return f"{emoji}"
flips = " ".join(["🟡 H" if r == "Heads" else "⚪ T" for r in results])
return f"🪙 Flipped {n} coins:\n{flips}\n\nHeads: {heads} | Tails: {tails}"
except ValueError:
return f"❌ Error: '{times}' is not a valid number."
except Exception as e:
logger.error(f"flip_coin error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_dice(notation: str = "1d6") -> str:
"""Roll dice using standard NdM+K notation, e.g. 2d6, 1d20+5, 4d8-2."""
logger.info(f"roll_dice called with notation={notation}")
try:
if not notation.strip():
notation = "1d6"
count, sides, modifier = parse_dice_notation(notation)
if count is None:
return f"❌ Error: Invalid notation '{notation}'. Use format like 2d6, 1d20+5, or 3d8-1."
if count < 1 or count > 100:
return "❌ Error: Die count must be between 1 and 100."
if sides < 2 or sides > 10000:
return "❌ Error: Sides must be between 2 and 10000."
rolls = [random.randint(1, sides) for _ in range(count)]
total = sum(rolls) + modifier
roll_display = ", ".join(
[f"{DICE_FACES.get(r, str(r))}" if sides == 6 else str(r) for r in rolls]
)
lines = [f"🎲 Rolling {notation.upper()}"]
lines.append(f"Rolls: [{roll_display}]")
if count > 1:
lines.append(f"Sum of rolls: {sum(rolls)}")
if modifier != 0:
mod_str = f"+{modifier}" if modifier > 0 else str(modifier)
lines.append(f"Modifier: {mod_str}")
lines.append(f"✅ Total: {total}")
return "\n".join(lines)
except Exception as e:
logger.error(f"roll_dice error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_advantage(sides: str = "20", modifier: str = "0") -> str:
"""Roll with advantage (roll twice, take the higher result) for DnD 5e."""
logger.info(f"roll_advantage called sides={sides} modifier={modifier}")
try:
s = int(sides.strip()) if sides.strip() else 20
mod = int(modifier.strip()) if modifier.strip() else 0
if s < 2:
return "❌ Error: Sides must be at least 2."
r1 = random.randint(1, s)
r2 = random.randint(1, s)
chosen = max(r1, r2)
total = chosen + mod
mod_str = f" + {mod}" if mod > 0 else (f" - {abs(mod)}" if mod < 0 else "")
return (
f"🎲 Advantage Roll (d{s}{mod_str})\n"
f"Roll 1: {r1} | Roll 2: {r2}\n"
f"✅ Higher: {chosen}{mod_str} = {total}"
)
except ValueError:
return "❌ Error: Invalid sides or modifier value."
except Exception as e:
logger.error(f"roll_advantage error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_disadvantage(sides: str = "20", modifier: str = "0") -> str:
"""Roll with disadvantage (roll twice, take the lower result) for DnD 5e."""
logger.info(f"roll_disadvantage called sides={sides} modifier={modifier}")
try:
s = int(sides.strip()) if sides.strip() else 20
mod = int(modifier.strip()) if modifier.strip() else 0
if s < 2:
return "❌ Error: Sides must be at least 2."
r1 = random.randint(1, s)
r2 = random.randint(1, s)
chosen = min(r1, r2)
total = chosen + mod
mod_str = f" + {mod}" if mod > 0 else (f" - {abs(mod)}" if mod < 0 else "")
return (
f"🎲 Disadvantage Roll (d{s}{mod_str})\n"
f"Roll 1: {r1} | Roll 2: {r2}\n"
f"✅ Lower: {chosen}{mod_str} = {total}"
)
except ValueError:
return "❌ Error: Invalid sides or modifier value."
except Exception as e:
logger.error(f"roll_disadvantage error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_stats() -> str:
"""Roll a full DnD character stat block using 4d6 drop lowest for all 6 stats."""
logger.info("roll_stats called")
try:
stat_names = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
results = []
for stat in stat_names:
rolls = [random.randint(1, 6) for _ in range(4)]
dropped = min(rolls)
kept = sorted([r for r in rolls], reverse=True)[:3]
total = sum(kept)
mod = (total - 10) // 2
mod_str = f"+{mod}" if mod >= 0 else str(mod)
results.append((stat, rolls, dropped, total, mod_str))
lines = ["🎲 DnD Character Stats (4d6 drop lowest)\n"]
lines.append(f"{'Stat':<5} {'Rolls':<18} {'Dropped':<9} {'Score':<7} {'Mod'}")
lines.append("-" * 48)
for stat, rolls, dropped, total, mod_str in results:
roll_str = str(sorted(rolls, reverse=True))
lines.append(f"{stat:<5} {str(rolls):<18} {dropped:<9} {total:<7} {mod_str}")
total_score = sum(r[3] for r in results)
lines.append(f"\n📊 Total Points: {total_score}")
return "\n".join(lines)
except Exception as e:
logger.error(f"roll_stats error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_initiative(modifier: str = "0") -> str:
"""Roll initiative (1d20 + modifier) for DnD combat."""
logger.info(f"roll_initiative called modifier={modifier}")
try:
mod = int(modifier.strip()) if modifier.strip() else 0
roll = random.randint(1, 20)
total = roll + mod
nat = " 🌟 Natural 20!" if roll == 20 else (" 💀 Natural 1!" if roll == 1 else "")
mod_str = f"+{mod}" if mod > 0 else (str(mod) if mod < 0 else "")
return (
f"⚡ Initiative Roll (d20{mod_str})\n"
f"Roll: {roll}{nat}\n"
f"✅ Initiative: {total}"
)
except ValueError:
return "❌ Error: Invalid modifier value."
except Exception as e:
logger.error(f"roll_initiative error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_custom(count: str = "1", sides: str = "6", modifier: str = "0", label: str = "") -> str:
"""Roll any number of any-sided dice with a modifier and optional label."""
logger.info(f"roll_custom count={count} sides={sides} modifier={modifier} label={label}")
try:
n = int(count.strip()) if count.strip() else 1
s = int(sides.strip()) if sides.strip() else 6
mod = int(modifier.strip()) if modifier.strip() else 0
if n < 1 or n > 100:
return "❌ Error: Count must be between 1 and 100."
if s < 2 or s > 10000:
return "❌ Error: Sides must be between 2 and 10000."
rolls = [random.randint(1, s) for _ in range(n)]
total = sum(rolls) + mod
roll_str = ", ".join(str(r) for r in rolls)
lbl = f" [{label}]" if label.strip() else ""
mod_str = f" + {mod}" if mod > 0 else (f" - {abs(mod)}" if mod < 0 else "")
return (
f"🎲 Custom Roll{lbl}: {n}d{s}{mod_str}\n"
f"Rolls: [{roll_str}]\n"
f"Sum: {sum(rolls)}{mod_str}\n"
f"✅ Total: {total}"
)
except ValueError:
return "❌ Error: Invalid number values."
except Exception as e:
logger.error(f"roll_custom error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_percentile() -> str:
"""Roll a percentile (d100) die."""
logger.info("roll_percentile called")
try:
result = random.randint(1, 100)
bar = "█" * (result // 10) + "░" * (10 - result // 10)
return f"🎲 Percentile Roll (d100)\n[{bar}]\n✅ Result: {result}%"
except Exception as e:
logger.error(f"roll_percentile error: {e}")
return f"❌ Error: {str(e)}"
@mcp.tool()
async def roll_drop_lowest(count: str = "4", sides: str = "6") -> str:
"""Roll NdM and drop the lowest die, commonly used for stat rolling."""
logger.info(f"roll_drop_lowest count={count} sides={sides}")
try:
n = int(count.strip()) if count.strip() else 4
s = int(sides.strip()) if sides.strip() else 6
if n < 2 or n > 20:
return "❌ Error: Count must be between 2 and 20."
if s < 2 or s > 1000:
return "❌ Error: Sides must be between 2 and 1000."
rolls = [random.randint(1, s) for _ in range(n)]
dropped = min(rolls)
kept = sorted(rolls, reverse=True)[:n-1]
total = sum(kept)
return (
f"🎲 Roll {n}d{s} Drop Lowest\n"
f"All rolls: {sorted(rolls, reverse=True)}\n"
f"Dropped: {dropped}\n"
f"Kept: {kept}\n"
f"✅ Total: {total}"
)
except ValueError:
return "❌ Error: Invalid count or sides."
except Exception as e:
logger.error(f"roll_drop_lowest error: {e}")
return f"❌ Error: {str(e)}"
if __name__ == "__main__":
logger.info("Starting Dice Roller MCP server...")
try:
mcp.run(transport='stdio')
except Exception as e:
logger.error(f"Server error: {e}", exc_info=True)
sys.exit(1)