#!/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()