DnD_Campaign_Manager / mcp_server /dnd_mcp_server.py
official.ghost.logic
Deploy D&D Campaign Manager v2
71b378e
#!/usr/bin/env python3
"""
D&D Campaign Manager MCP Server
Provides tools for D&D 5e rules, character validation, encounter design,
and campaign management through the Model Context Protocol.
Tools:
- validate_character: Validate D&D 5e character builds
- calculate_encounter_cr: Calculate encounter difficulty
- lookup_spell: Get spell details and rules
- lookup_monster: Get monster stat blocks
- generate_npc: Create balanced NPC stat blocks
- calculate_xp: Calculate XP awards for encounters
- validate_multiclass: Check multiclass requirements
- get_ability_modifier: Calculate ability modifiers
"""
import json
import sys
from typing import Any, Dict, List, Optional
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
print("Installing FastMCP...")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "fastmcp"])
from mcp.server.fastmcp import FastMCP
# Initialize MCP server
mcp = FastMCP("dnd-campaign-manager")
# ============================================================================
# D&D 5E RULES DATA
# ============================================================================
ABILITY_SCORES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]
CLASSES = {
"barbarian": {"hit_die": 12, "primary_ability": ["strength"]},
"bard": {"hit_die": 8, "primary_ability": ["charisma"]},
"cleric": {"hit_die": 8, "primary_ability": ["wisdom"]},
"druid": {"hit_die": 8, "primary_ability": ["wisdom"]},
"fighter": {"hit_die": 10, "primary_ability": ["strength", "dexterity"]},
"monk": {"hit_die": 8, "primary_ability": ["dexterity", "wisdom"]},
"paladin": {"hit_die": 10, "primary_ability": ["strength", "charisma"]},
"ranger": {"hit_die": 10, "primary_ability": ["dexterity", "wisdom"]},
"rogue": {"hit_die": 8, "primary_ability": ["dexterity"]},
"sorcerer": {"hit_die": 6, "primary_ability": ["charisma"]},
"warlock": {"hit_die": 8, "primary_ability": ["charisma"]},
"wizard": {"hit_die": 6, "primary_ability": ["intelligence"]}
}
MULTICLASS_REQUIREMENTS = {
"barbarian": {"strength": 13},
"bard": {"charisma": 13},
"cleric": {"wisdom": 13},
"druid": {"wisdom": 13},
"fighter": {"strength": 13, "dexterity": 13}, # OR condition
"monk": {"dexterity": 13, "wisdom": 13},
"paladin": {"strength": 13, "charisma": 13},
"ranger": {"dexterity": 13, "wisdom": 13},
"rogue": {"dexterity": 13},
"sorcerer": {"charisma": 13},
"warlock": {"charisma": 13},
"wizard": {"intelligence": 13}
}
XP_THRESHOLDS = {
1: {"easy": 25, "medium": 50, "hard": 75, "deadly": 100},
2: {"easy": 50, "medium": 100, "hard": 150, "deadly": 200},
3: {"easy": 75, "medium": 150, "hard": 225, "deadly": 400},
4: {"easy": 125, "medium": 250, "hard": 375, "deadly": 500},
5: {"easy": 250, "medium": 500, "hard": 750, "deadly": 1100},
6: {"easy": 300, "medium": 600, "hard": 900, "deadly": 1400},
7: {"easy": 350, "medium": 750, "hard": 1100, "deadly": 1700},
8: {"easy": 450, "medium": 900, "hard": 1400, "deadly": 2100},
9: {"easy": 550, "medium": 1100, "hard": 1600, "deadly": 2400},
10: {"easy": 600, "medium": 1200, "hard": 1900, "deadly": 2800},
11: {"easy": 800, "medium": 1600, "hard": 2400, "deadly": 3600},
12: {"easy": 1000, "medium": 2000, "hard": 3000, "deadly": 4500},
13: {"easy": 1100, "medium": 2200, "hard": 3400, "deadly": 5100},
14: {"easy": 1250, "medium": 2500, "hard": 3800, "deadly": 5700},
15: {"easy": 1400, "medium": 2800, "hard": 4300, "deadly": 6400},
16: {"easy": 1600, "medium": 3200, "hard": 4800, "deadly": 7200},
17: {"easy": 2000, "medium": 3900, "hard": 5900, "deadly": 8800},
18: {"easy": 2100, "medium": 4200, "hard": 6300, "deadly": 9500},
19: {"easy": 2400, "medium": 4900, "hard": 7300, "deadly": 10900},
20: {"easy": 2800, "medium": 5700, "hard": 8500, "deadly": 12700}
}
CR_TO_XP = {
0: 10, 0.125: 25, 0.25: 50, 0.5: 100,
1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800,
6: 2300, 7: 2900, 8: 3900, 9: 5000, 10: 5900,
11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000,
16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000,
21: 33000, 22: 41000, 23: 50000, 24: 62000, 25: 75000,
26: 90000, 27: 105000, 28: 120000, 29: 135000, 30: 155000
}
ENCOUNTER_MULTIPLIERS = {
1: 1.0,
2: 1.5,
(3, 6): 2.0,
(7, 10): 2.5,
(11, 14): 3.0,
15: 4.0
}
# ============================================================================
# MCP TOOLS
# ============================================================================
@mcp.tool()
def get_ability_modifier(ability_score: int) -> int:
"""
Calculate ability modifier from ability score.
Args:
ability_score: The ability score (1-30)
Returns:
The ability modifier
Example:
>>> get_ability_modifier(16)
3
"""
return (ability_score - 10) // 2
@mcp.tool()
def validate_character(
character_class: str,
level: int,
ability_scores: Dict[str, int],
multiclass: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Validate a D&D 5e character build for rules compliance.
Args:
character_class: Primary class name (e.g., "fighter")
level: Character level (1-20)
ability_scores: Dict of ability scores (e.g., {"strength": 16, "dexterity": 14, ...})
multiclass: Optional list of multiclass names
Returns:
Validation result with errors and warnings
Example:
>>> validate_character("fighter", 5, {"strength": 16, "dexterity": 14, "constitution": 15, "intelligence": 10, "wisdom": 12, "charisma": 8})
{"valid": True, "errors": [], "warnings": [], "hp_estimate": 49}
"""
errors = []
warnings = []
# Validate class
character_class = character_class.lower()
if character_class not in CLASSES:
errors.append(f"Invalid class: {character_class}")
return {"valid": False, "errors": errors, "warnings": warnings}
# Validate level
if level < 1 or level > 20:
errors.append(f"Level must be between 1 and 20, got {level}")
# Validate ability scores
for ability in ABILITY_SCORES:
if ability not in ability_scores:
errors.append(f"Missing ability score: {ability}")
elif ability_scores[ability] < 1 or ability_scores[ability] > 20:
warnings.append(f"{ability.title()} score {ability_scores[ability]} is unusual (normally 1-20)")
# Check primary ability scores
class_info = CLASSES[character_class]
for primary in class_info["primary_ability"]:
if primary in ability_scores and ability_scores[primary] < 13:
warnings.append(f"Low {primary} ({ability_scores[primary]}) for {character_class} - recommend 13+")
# Validate multiclassing
if multiclass:
for mc_class in multiclass:
mc_class = mc_class.lower()
if mc_class not in CLASSES:
errors.append(f"Invalid multiclass: {mc_class}")
continue
requirements = MULTICLASS_REQUIREMENTS[mc_class]
for ability, min_score in requirements.items():
if ability_scores.get(ability, 0) < min_score:
errors.append(
f"Multiclass {mc_class} requires {ability} {min_score}, "
f"but character has {ability_scores.get(ability, 0)}"
)
# Calculate estimated HP
hit_die = class_info["hit_die"]
con_mod = get_ability_modifier(ability_scores.get("constitution", 10))
hp_estimate = hit_die + (level - 1) * (hit_die // 2 + 1) + (level * con_mod)
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"hp_estimate": hp_estimate,
"hit_die": f"d{hit_die}",
"constitution_modifier": con_mod
}
@mcp.tool()
def calculate_encounter_cr(
party_levels: List[int],
monster_crs: List[float],
difficulty_target: str = "medium"
) -> Dict[str, Any]:
"""
Calculate encounter difficulty for D&D 5e.
Args:
party_levels: List of party member levels (e.g., [5, 5, 4, 6])
monster_crs: List of monster CRs (e.g., [2, 2, 0.5])
difficulty_target: Target difficulty ("easy", "medium", "hard", "deadly")
Returns:
Encounter analysis with difficulty rating and recommendations
Example:
>>> calculate_encounter_cr([5, 5, 4, 6], [2, 2, 0.5], "medium")
{"difficulty": "hard", "adjusted_xp": 2100, "threshold": {"easy": 1000, "medium": 2000, "hard": 3000, "deadly": 4400}}
"""
# Calculate party XP thresholds
party_thresholds = {"easy": 0, "medium": 0, "hard": 0, "deadly": 0}
for level in party_levels:
level = min(max(level, 1), 20) # Clamp to 1-20
for difficulty in ["easy", "medium", "hard", "deadly"]:
party_thresholds[difficulty] += XP_THRESHOLDS[level][difficulty]
# Calculate monster XP
total_monster_xp = sum(CR_TO_XP.get(cr, 0) for cr in monster_crs)
# Apply encounter multiplier based on number of monsters
num_monsters = len(monster_crs)
multiplier = 1.0
if num_monsters == 1:
multiplier = 1.0
elif num_monsters == 2:
multiplier = 1.5
elif 3 <= num_monsters <= 6:
multiplier = 2.0
elif 7 <= num_monsters <= 10:
multiplier = 2.5
elif 11 <= num_monsters <= 14:
multiplier = 3.0
else:
multiplier = 4.0
adjusted_xp = int(total_monster_xp * multiplier)
# Determine difficulty
if adjusted_xp < party_thresholds["easy"]:
difficulty = "trivial"
elif adjusted_xp < party_thresholds["medium"]:
difficulty = "easy"
elif adjusted_xp < party_thresholds["hard"]:
difficulty = "medium"
elif adjusted_xp < party_thresholds["deadly"]:
difficulty = "hard"
else:
difficulty = "deadly"
# Calculate recommendations
recommendations = []
if difficulty == "trivial":
recommendations.append("This encounter is too easy. Consider adding more monsters or increasing CR.")
elif difficulty == "deadly":
recommendations.append("This encounter is deadly! Ensure party has resources and escape options.")
if num_monsters > 6:
recommendations.append(f"Large number of monsters ({num_monsters}) may slow combat. Consider grouping or reducing.")
return {
"difficulty": difficulty,
"adjusted_xp": adjusted_xp,
"raw_xp": total_monster_xp,
"multiplier": multiplier,
"thresholds": party_thresholds,
"target_met": difficulty == difficulty_target,
"recommendations": recommendations,
"party_size": len(party_levels),
"monster_count": num_monsters
}
@mcp.tool()
def calculate_xp_award(
party_size: int,
monster_crs: List[float]
) -> Dict[str, int]:
"""
Calculate XP award per player for defeating monsters.
Args:
party_size: Number of players in party
monster_crs: List of monster CRs defeated
Returns:
XP breakdown
Example:
>>> calculate_xp_award(4, [2, 2, 0.5])
{"total_xp": 1150, "xp_per_player": 287}
"""
total_xp = sum(CR_TO_XP.get(cr, 0) for cr in monster_crs)
xp_per_player = total_xp // party_size if party_size > 0 else 0
return {
"total_xp": total_xp,
"xp_per_player": xp_per_player,
"party_size": party_size,
"monsters_defeated": len(monster_crs)
}
@mcp.tool()
def validate_multiclass(
current_class: str,
target_class: str,
ability_scores: Dict[str, int]
) -> Dict[str, Any]:
"""
Check if character meets multiclass requirements.
Args:
current_class: Current primary class
target_class: Class to multiclass into
ability_scores: Character's ability scores
Returns:
Validation result with requirements
Example:
>>> validate_multiclass("fighter", "wizard", {"intelligence": 14, "strength": 16})
{"valid": True, "requirements_met": True, "required": {"intelligence": 13}}
"""
current_class = current_class.lower()
target_class = target_class.lower()
if current_class not in CLASSES:
return {"valid": False, "error": f"Invalid current class: {current_class}"}
if target_class not in CLASSES:
return {"valid": False, "error": f"Invalid target class: {target_class}"}
# Check target class requirements
requirements = MULTICLASS_REQUIREMENTS[target_class]
requirements_met = True
missing = []
for ability, min_score in requirements.items():
if ability_scores.get(ability, 0) < min_score:
requirements_met = False
missing.append(f"{ability.title()} {min_score} (currently {ability_scores.get(ability, 0)})")
return {
"valid": True,
"requirements_met": requirements_met,
"required": requirements,
"missing": missing,
"can_multiclass": requirements_met
}
@mcp.tool()
def generate_npc_stats(
npc_name: str,
character_class: str,
level: int,
role: str = "standard"
) -> Dict[str, Any]:
"""
Generate NPC stat block for D&D 5e.
Args:
npc_name: NPC name
character_class: NPC class
level: NPC level
role: Combat role ("tank", "damage", "support", "standard")
Returns:
NPC stat block
Example:
>>> generate_npc_stats("Guard Captain", "fighter", 5, "tank")
{"name": "Guard Captain", "class": "fighter", "level": 5, "hp": 49, "ac": 18, ...}
"""
character_class = character_class.lower()
if character_class not in CLASSES:
return {"error": f"Invalid class: {character_class}"}
class_info = CLASSES[character_class]
# Generate ability scores based on role
if role == "tank":
abilities = {"strength": 16, "dexterity": 12, "constitution": 16, "intelligence": 10, "wisdom": 12, "charisma": 10}
elif role == "damage":
abilities = {"strength": 16, "dexterity": 16, "constitution": 14, "intelligence": 10, "wisdom": 12, "charisma": 10}
elif role == "support":
abilities = {"strength": 10, "dexterity": 12, "constitution": 14, "intelligence": 14, "wisdom": 16, "charisma": 14}
else: # standard
abilities = {"strength": 14, "dexterity": 14, "constitution": 14, "intelligence": 12, "wisdom": 12, "charisma": 12}
# Calculate stats
hit_die = class_info["hit_die"]
con_mod = get_ability_modifier(abilities["constitution"])
hp = hit_die + (level - 1) * (hit_die // 2 + 1) + (level * con_mod)
# Estimate AC based on class and role
base_ac = {"barbarian": 13, "fighter": 16, "paladin": 16, "ranger": 15, "rogue": 14}.get(character_class, 12)
if role == "tank":
base_ac += 2
# Proficiency bonus
proficiency = 2 + ((level - 1) // 4)
return {
"name": npc_name,
"class": character_class,
"level": level,
"role": role,
"hp": hp,
"hp_formula": f"{level}d{hit_die} + {level * con_mod}",
"ac": base_ac,
"ability_scores": abilities,
"proficiency_bonus": proficiency,
"primary_ability": class_info["primary_ability"][0],
"hit_die": f"d{hit_die}"
}
@mcp.tool()
def get_cr_for_solo_monster(party_level: int, party_size: int, difficulty: str = "medium") -> float:
"""
Calculate appropriate CR for a solo monster encounter.
Args:
party_level: Average party level
party_size: Number of party members
difficulty: Desired difficulty ("easy", "medium", "hard", "deadly")
Returns:
Recommended CR
Example:
>>> get_cr_for_solo_monster(5, 4, "hard")
7.0
"""
party_level = min(max(party_level, 1), 20)
# Get total party threshold for difficulty
total_threshold = XP_THRESHOLDS[party_level][difficulty] * party_size
# For solo monster, no multiplier
target_xp = total_threshold
# Find closest CR
best_cr = 0
best_diff = float('inf')
for cr, xp in CR_TO_XP.items():
diff = abs(xp - target_xp)
if diff < best_diff:
best_diff = diff
best_cr = cr
return {
"recommended_cr": best_cr,
"target_xp": target_xp,
"actual_xp": CR_TO_XP[best_cr],
"party_level": party_level,
"party_size": party_size,
"difficulty": difficulty
}
# ============================================================================
# MCP SERVER MAIN
# ============================================================================
if __name__ == "__main__":
print("🎲 D&D Campaign Manager MCP Server")
print("=" * 50)
print("Available Tools:")
print(" - validate_character: Validate character builds")
print(" - calculate_encounter_cr: Calculate encounter difficulty")
print(" - calculate_xp_award: Calculate XP rewards")
print(" - validate_multiclass: Check multiclass requirements")
print(" - generate_npc_stats: Generate NPC stat blocks")
print(" - get_ability_modifier: Calculate ability modifiers")
print(" - get_cr_for_solo_monster: Get CR for solo encounters")
print("=" * 50)
print("Starting MCP server...")
mcp.run()