Spaces:
Sleeping
Sleeping
| #!/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 | |
| # ============================================================================ | |
| 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 | |
| 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 | |
| } | |
| 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 | |
| } | |
| 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) | |
| } | |
| 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 | |
| } | |
| 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}" | |
| } | |
| 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() | |